diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 9a9ce0c..63f91c4 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -91,22 +91,14 @@ func main() { // Create adapters (dependency injection) namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev") - // Initialize K8s client for dynamic project discovery - // Falls back gracefully if K8s is unavailable (e.g., local development) + // Initialize K8s client (falls back gracefully if unavailable) k8sClient := kubernetes.NewClientOrNil(kubernetes.ClientConfig{ Namespace: namespace, 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) k8sExecutor := kubernetes.NewExecutor(namespace) streamPub := memory.NewStreamPublisher() - if k8sClient != nil { if err := projectRepo.StartWatching(context.Background()); err != nil { 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) if err != nil { 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 if infraCfg.CloudflareToken != "" && infraCfg.CloudflareZoneID != "" { dnsClient = cloudflare.NewClient(infraCfg.CloudflareToken, infraCfg.CloudflareZoneID, infraCfg.DefaultDomain) - logger.Info("cloudflare DNS client initialized", "domain", infraCfg.DefaultDomain) } var deployerAdapter *deployer.Deployer @@ -159,30 +147,18 @@ func main() { DefaultDomain: infraCfg.DefaultDomain, DefaultReplicas: 1, }) - logger.Info("deployer initialized", "namespace", infraCfg.DeployNamespace) } - var woodpeckerClient *woodpecker.Client if infraCfg.WoodpeckerURL != "" && infraCfg.WoodpeckerAPIToken != "" { var err error - woodpeckerClient, err = woodpecker.NewClient( - infraCfg.WoodpeckerURL, - infraCfg.WoodpeckerAPIToken, - woodpecker.WithLogger(logger), - ) + woodpeckerClient, err = woodpecker.NewClient(infraCfg.WoodpeckerURL, infraCfg.WoodpeckerAPIToken, woodpecker.WithLogger(logger)) if err != nil { 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 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) - logger.Info("template provider initialized") } // Initialize database provisioner (optional - for project database isolation) @@ -345,7 +321,8 @@ func main() { ) // 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) var componentsHandler *handlers.ComponentsHandler @@ -362,7 +339,8 @@ func main() { Logger: logger, }, ) - componentsHandler = handlers.NewComponentsHandler(componentService, logger) + componentsHandler = handlers.NewComponentsHandler(componentService, logger). + SetOperationService(operationService) logger.Info("component service initialized") } @@ -377,7 +355,7 @@ func main() { ClusterIP: infraCfg.ClusterIP, Logger: logger, }, - ) + ).SetOperationService(operationService) // Initialize credentials handler (superadmin only) credentialsHandler := handlers.NewCredentialsHandler(credentialStore) @@ -393,9 +371,6 @@ func main() { // Initialize operations handler (for debugging project failures) 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 healthHandler := handlers.NewHealthHandler("rdev-api", database.DB, nil). WithAgentRegistry(agentRegistry) @@ -518,5 +493,3 @@ func main() { app.Run() } - -// Config, InfraConfig, loadConfig, loadInfraConfig are defined in config.go. diff --git a/cookbooks/feature-development.md b/cookbooks/feature-development.md new file mode 100644 index 0000000..ea4a6a5 --- /dev/null +++ b/cookbooks/feature-development.md @@ -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="" +``` + +### 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 ( +
+ + + Profile Settings + + + + + +
+ ); +} +``` + +### 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(null); + + if (!user) { + return
Loading...
; + } + + 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 ( +
+
+ + +
+ +
+ + setName(e.target.value)} + placeholder="Enter your name" + required + /> +
+ +
+ + setAvatarUrl(e.target.value)} + placeholder="https://example.com/avatar.png" + /> +
+ + {error && ( +
{error}
+ )} + + +
+ ); +} +``` + +### 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 { + 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) diff --git a/cookbooks/scripts/feature-test.sh b/cookbooks/scripts/feature-test.sh new file mode 100755 index 0000000..32e0116 --- /dev/null +++ b/cookbooks/scripts/feature-test.sh @@ -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 +# 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 " + 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 diff --git a/cookbooks/scripts/template-validation.sh b/cookbooks/scripts/template-validation.sh new file mode 100755 index 0000000..26b181c --- /dev/null +++ b/cookbooks/scripts/template-validation.sh @@ -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 +# 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 " + 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 diff --git a/docs/plans/sdlc-orchestration-breakdown.md b/docs/plans/sdlc-orchestration-breakdown.md new file mode 100644 index 0000000..7597aa9 --- /dev/null +++ b/docs/plans/sdlc-orchestration-breakdown.md @@ -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 # 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 # Create new feature +sdlc feature list # List all features +sdlc feature show # Show feature details +sdlc feature status # Show phase + progress +sdlc feature transition # Manual transition +sdlc feature block # Add blocker +sdlc feature unblock # Remove blocker + +sdlc artifact create # Create artifact file +sdlc artifact approve # Mark approved +sdlc artifact reject # Mark rejected +sdlc artifact status # Show all statuses + +sdlc task list # List tasks +sdlc task start # Mark in-progress +sdlc task complete # Mark complete +sdlc task add # 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 diff --git a/docs/specs/sdlc-orchestration-system.md b/docs/specs/sdlc-orchestration-system.md new file mode 100644 index 0000000..6cb5485 --- /dev/null +++ b/docs/specs/sdlc-orchestration-system.md @@ -0,0 +1,2190 @@ +# SDLC Orchestration System Specification + +## Overview + +A deterministic, adaptive software development lifecycle system that enforces enterprise-grade development practices through structured artifacts, state management, and intelligent orchestration. + +**Core Principles:** +1. Every command produces a concrete artifact at a deterministic location +2. Git is the source of truth for all SDLC state +3. A classifier engine recursively determines the next required action +4. All work happens on branches until verified and approved +5. The system is self-documenting and auditable + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SDLC Orchestration System │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Claude │───▶│ Classifier │───▶│ SDLC CLI │───▶│ Git │ │ +│ │ Commands │ │ Engine │ │ Tool │ │ Structure │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ .sdlc/ Directory │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ roadmap/ │ │features/ │ │patterns/ │ │ audits/ │ │ state/ │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Git Structure (Source of Truth) + +All SDLC artifacts live in `.sdlc/` at the repository root: + +``` +.sdlc/ +├── state.yaml # Global SDLC state (current phase, active work) +├── config.yaml # Project SDLC configuration +│ +├── roadmap/ # Strategic planning artifacts +│ ├── index.md # Roadmap overview with links +│ ├── v1.0/ +│ │ ├── roadmap.md # Version roadmap document +│ │ ├── milestones.md # Milestone definitions +│ │ └── releases.md # Release schedule +│ └── backlog.md # Unprioritized ideas +│ +├── features/ # Feature development artifacts +│ ├── index.md # Feature registry with status +│ └── {feature-slug}/ +│ ├── manifest.yaml # Feature metadata and state machine +│ ├── spec.md # Canonical specification (immutable after approval) +│ ├── design.md # Architecture/design decisions +│ ├── tasks.md # Implementation task breakdown +│ ├── qa-plan.md # QA verification flow +│ ├── qa-results.md # QA execution results +│ ├── review.md # Code review findings +│ ├── audit.md # Post-implementation audit +│ └── changelog.md # Feature development history +│ +├── patterns/ # Elevated patterns (canonical references) +│ ├── index.md # Pattern catalog +│ └── {pattern-slug}/ +│ ├── pattern.md # Pattern specification +│ ├── examples.md # Code examples +│ ├── violations.md # Known violations to fix +│ └── adoption.md # Adoption tracking +│ +├── audits/ # Codebase-wide audits +│ ├── index.md # Audit history +│ └── {audit-date}-{type}.md # Individual audit reports +│ +├── branches/ # Branch lifecycle tracking +│ ├── index.md # Active branches +│ └── {branch-name}.yaml # Branch state and checklist +│ +└── archives/ # Completed features (moved after release) + └── {feature-slug}/ # Same structure as features/ +``` + +--- + +## State Machine + +### Global States + +```yaml +# .sdlc/state.yaml +version: 1 +project: + name: "project-name" + current_roadmap: "v1.0" + +active_work: + features: + - slug: "auth" + branch: "feature/auth" + phase: "implementation" + + patterns: + - slug: "error-handling" + phase: "elevation" + + audits: + - type: "api-consistency" + phase: "remediation" + +blocked: [] + +last_updated: "2024-01-15T10:30:00Z" +last_action: "task-complete" +last_actor: "claude" +``` + +### Feature Lifecycle Phases + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Feature Lifecycle State Machine │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ DRAFT │───▶│SPECIFIED │───▶│ PLANNED │───▶│ READY │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ │ │ │ │ +│ │ spec.md │ design.md │ tasks.md │ branch created │ +│ │ created │ approved │ qa-plan.md │ all prereqs met │ +│ │ │ │ approved │ │ +│ ▼ ▼ ▼ ▼ │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │IMPLEMENT │───▶│ REVIEW │───▶│ AUDIT │───▶│ QA │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ │ │ │ │ +│ │ tasks done │ review.md │ audit.md │ qa-results.md │ +│ │ │ all PASS │ all PASS │ all PASS │ +│ ▼ ▼ ▼ ▼ │ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ MERGE │───▶│ RELEASED │ │ +│ └──────────┘ └──────────┘ │ +│ │ │ │ +│ │ PR merged │ archived │ +│ │ main updated │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Feature Manifest (State Tracking) + +```yaml +# .sdlc/features/auth/manifest.yaml +slug: "auth" +title: "User Authentication System" +created: "2024-01-10T09:00:00Z" +branch: "feature/auth" +roadmap_ref: "v1.0/M2" + +phase: "implementation" +phase_history: + - phase: "draft" + entered: "2024-01-10T09:00:00Z" + exited: "2024-01-10T14:00:00Z" + - phase: "specified" + entered: "2024-01-10T14:00:00Z" + exited: "2024-01-11T10:00:00Z" + - phase: "planned" + entered: "2024-01-11T10:00:00Z" + exited: "2024-01-11T16:00:00Z" + - phase: "ready" + entered: "2024-01-11T16:00:00Z" + exited: "2024-01-12T09:00:00Z" + - phase: "implementation" + entered: "2024-01-12T09:00:00Z" + exited: null + +artifacts: + spec: + status: "approved" + path: "spec.md" + approved_by: "user" + approved_at: "2024-01-10T14:00:00Z" + design: + status: "approved" + path: "design.md" + approved_by: "user" + approved_at: "2024-01-11T10:00:00Z" + tasks: + status: "approved" + path: "tasks.md" + total: 8 + completed: 5 + in_progress: 1 + blocked: 0 + qa_plan: + status: "approved" + path: "qa-plan.md" + checkpoints: 12 + review: + status: "pending" + path: "review.md" + audit: + status: "pending" + path: "audit.md" + qa_results: + status: "pending" + path: "qa-results.md" + +blockers: [] +dependencies: + features: [] + patterns: ["error-handling"] +``` + +--- + +## Classifier Engine + +The classifier is a deterministic rules engine that evaluates current state and determines the next required action. + +### Classifier Interface + +``` +Input: Current state (.sdlc/state.yaml + feature manifests) +Output: Next action (command + target + context) +``` + +### Classification Rules (Priority Order) + +```yaml +# Classifier decision tree (evaluated top-to-bottom, first match wins) + +rules: + # ============================================ + # BLOCKERS (highest priority) + # ============================================ + - id: "blocked-dependency" + condition: "feature.blockers.length > 0" + action: "BLOCKED" + message: "Feature blocked by: {blockers}" + next_command: null + + # ============================================ + # PHASE: DRAFT → SPECIFIED + # ============================================ + - id: "needs-spec" + condition: "feature.phase == 'draft' AND NOT exists(spec.md)" + action: "CREATE_SPEC" + next_command: "/spec-feature {feature}" + output_path: ".sdlc/features/{feature}/spec.md" + + - id: "spec-needs-approval" + condition: "feature.phase == 'draft' AND spec.status == 'draft'" + action: "AWAIT_APPROVAL" + message: "Spec requires user approval" + next_command: null + + - id: "spec-approved" + condition: "feature.phase == 'draft' AND spec.status == 'approved'" + action: "TRANSITION" + transition_to: "specified" + + # ============================================ + # PHASE: SPECIFIED → PLANNED + # ============================================ + - id: "needs-design" + condition: "feature.phase == 'specified' AND NOT exists(design.md)" + action: "CREATE_DESIGN" + next_command: "/design-feature {feature}" + output_path: ".sdlc/features/{feature}/design.md" + + - id: "needs-tasks" + condition: "feature.phase == 'specified' AND design.status == 'approved' AND NOT exists(tasks.md)" + action: "CREATE_TASKS" + next_command: "/breakdown-feature {feature}" + output_path: ".sdlc/features/{feature}/tasks.md" + + - id: "needs-qa-plan" + condition: "feature.phase == 'specified' AND tasks.status == 'approved' AND NOT exists(qa-plan.md)" + action: "CREATE_QA_PLAN" + next_command: "/create-qa-plan {feature}" + output_path: ".sdlc/features/{feature}/qa-plan.md" + + - id: "planning-complete" + condition: "feature.phase == 'specified' AND qa_plan.status == 'approved'" + action: "TRANSITION" + transition_to: "planned" + + # ============================================ + # PHASE: PLANNED → READY + # ============================================ + - id: "needs-branch" + condition: "feature.phase == 'planned' AND NOT branch_exists(feature.branch)" + action: "CREATE_BRANCH" + next_command: "/create-branch {feature}" + + - id: "check-dependencies" + condition: "feature.phase == 'planned' AND has_unmet_dependencies(feature)" + action: "BLOCKED" + message: "Dependencies not met: {unmet_deps}" + + - id: "ready-to-implement" + condition: "feature.phase == 'planned' AND branch_exists AND dependencies_met" + action: "TRANSITION" + transition_to: "ready" + + # ============================================ + # PHASE: READY → IMPLEMENTATION + # ============================================ + - id: "start-implementation" + condition: "feature.phase == 'ready'" + action: "TRANSITION" + transition_to: "implementation" + + # ============================================ + # PHASE: IMPLEMENTATION + # ============================================ + - id: "implement-next-task" + condition: "feature.phase == 'implementation' AND has_pending_tasks()" + action: "IMPLEMENT_TASK" + next_command: "/implement-task {feature} {next_task_id}" + + - id: "implementation-complete" + condition: "feature.phase == 'implementation' AND all_tasks_complete()" + action: "TRANSITION" + transition_to: "review" + + # ============================================ + # PHASE: REVIEW + # ============================================ + - id: "needs-review" + condition: "feature.phase == 'review' AND review.status == 'pending'" + action: "REVIEW_CODE" + next_command: "/review-feature {feature}" + output_path: ".sdlc/features/{feature}/review.md" + + - id: "review-has-issues" + condition: "feature.phase == 'review' AND review.status == 'needs_fix'" + action: "FIX_REVIEW_ISSUES" + next_command: "/fix-review-issues {feature}" + + - id: "review-passed" + condition: "feature.phase == 'review' AND review.status == 'passed'" + action: "TRANSITION" + transition_to: "audit" + + # ============================================ + # PHASE: AUDIT + # ============================================ + - id: "needs-audit" + condition: "feature.phase == 'audit' AND audit.status == 'pending'" + action: "AUDIT_CODE" + next_command: "/audit-feature {feature}" + output_path: ".sdlc/features/{feature}/audit.md" + + - id: "audit-has-issues" + condition: "feature.phase == 'audit' AND audit.status == 'needs_remediation'" + action: "REMEDIATE_AUDIT" + next_command: "/remediate-audit {feature}" + + - id: "audit-passed" + condition: "feature.phase == 'audit' AND audit.status == 'passed'" + action: "TRANSITION" + transition_to: "qa" + + # ============================================ + # PHASE: QA + # ============================================ + - id: "needs-qa" + condition: "feature.phase == 'qa' AND qa_results.status == 'pending'" + action: "RUN_QA" + next_command: "/run-qa {feature}" + output_path: ".sdlc/features/{feature}/qa-results.md" + + - id: "qa-has-failures" + condition: "feature.phase == 'qa' AND qa_results.status == 'failed'" + action: "FIX_QA_FAILURES" + next_command: "/fix-qa-failures {feature}" + + - id: "qa-passed" + condition: "feature.phase == 'qa' AND qa_results.status == 'passed'" + action: "TRANSITION" + transition_to: "merge" + + # ============================================ + # PHASE: MERGE + # ============================================ + - id: "needs-merge" + condition: "feature.phase == 'merge'" + action: "MERGE_FEATURE" + next_command: "/merge-feature {feature}" + + # ============================================ + # PHASE: RELEASED + # ============================================ + - id: "archive-feature" + condition: "feature.phase == 'released'" + action: "ARCHIVE" + next_command: "/archive-feature {feature}" + + # ============================================ + # PATTERN LIFECYCLE + # ============================================ + - id: "pattern-needs-elevation" + condition: "pattern.phase == 'identified'" + action: "ELEVATE_PATTERN" + next_command: "/elevate-pattern {pattern}" + output_path: ".sdlc/patterns/{pattern}/pattern.md" + + - id: "pattern-needs-examples" + condition: "pattern.phase == 'documented' AND NOT exists(examples.md)" + action: "ADD_EXAMPLES" + next_command: "/add-pattern-examples {pattern}" + + - id: "pattern-needs-adoption" + condition: "pattern.phase == 'documented' AND examples.status == 'complete'" + action: "FIND_VIOLATIONS" + next_command: "/find-pattern-violations {pattern}" + output_path: ".sdlc/patterns/{pattern}/violations.md" + + - id: "pattern-has-violations" + condition: "pattern.violations.count > 0" + action: "FIX_VIOLATIONS" + next_command: "/fix-pattern-violations {pattern}" + + # ============================================ + # DEFAULT + # ============================================ + - id: "nothing-to-do" + condition: "true" + action: "IDLE" + message: "No actionable work found" +``` + +### Classifier Output Format + +```yaml +# Classifier response +classification: + timestamp: "2024-01-15T10:30:00Z" + context: + feature: "auth" + current_phase: "implementation" + + evaluation: + rule_matched: "implement-next-task" + conditions_checked: + - "feature.phase == 'implementation'" : true + - "has_pending_tasks()" : true + + decision: + action: "IMPLEMENT_TASK" + command: "/implement-task auth task-004" + output_path: ".sdlc/features/auth/tasks.md" + + context_for_command: + feature_slug: "auth" + task_id: "task-004" + task_title: "Implement JWT token validation" + task_spec: | + Validate JWT tokens on protected endpoints... + patterns_to_follow: + - "error-handling" + related_files: + - "internal/auth/jwt.go" + - "internal/middleware/auth.go" +``` + +--- + +## SDLC CLI Tool + +### Command Reference + +```bash +# ============================================ +# INITIALIZATION +# ============================================ +sdlc init # Initialize .sdlc/ structure +sdlc config set <key> <value> # Configure project settings + +# ============================================ +# ROADMAP MANAGEMENT +# ============================================ +sdlc roadmap create <version> # Create new roadmap version +sdlc roadmap list # List all roadmap versions +sdlc roadmap show <version> # Display roadmap +sdlc roadmap set-active <version> # Set active roadmap version + +# ============================================ +# FEATURE MANAGEMENT +# ============================================ +sdlc feature create <slug> # Create new feature +sdlc feature list # List all features with status +sdlc feature show <slug> # Show feature details +sdlc feature status <slug> # Show feature phase and progress +sdlc feature transition <slug> <phase> # Manually transition phase +sdlc feature block <slug> <reason> # Mark feature as blocked +sdlc feature unblock <slug> # Remove blocker + +# ============================================ +# ARTIFACT MANAGEMENT +# ============================================ +sdlc artifact create <feature> <type> # Create artifact file +sdlc artifact approve <feature> <type> # Mark artifact as approved +sdlc artifact reject <feature> <type> # Mark artifact as rejected +sdlc artifact status <feature> # Show all artifact statuses + +# ============================================ +# TASK MANAGEMENT +# ============================================ +sdlc task list <feature> # List tasks for feature +sdlc task start <feature> <task-id> # Mark task as in-progress +sdlc task complete <feature> <task-id> # Mark task as complete +sdlc task block <feature> <task-id> # Mark task as blocked +sdlc task add <feature> <title> # Add new task + +# ============================================ +# BRANCH MANAGEMENT +# ============================================ +sdlc branch create <feature> # Create feature branch +sdlc branch status <feature> # Show branch checklist +sdlc branch sync <feature> # Sync branch with main + +# ============================================ +# PATTERN MANAGEMENT +# ============================================ +sdlc pattern create <slug> # Create new pattern +sdlc pattern list # List all patterns +sdlc pattern show <slug> # Show pattern details +sdlc pattern adopt <slug> # Start adoption campaign + +# ============================================ +# AUDIT MANAGEMENT +# ============================================ +sdlc audit create <type> # Create new audit +sdlc audit list # List all audits +sdlc audit show <id> # Show audit details + +# ============================================ +# STATE & CLASSIFICATION +# ============================================ +sdlc state # Show current global state +sdlc next # Run classifier, show next action +sdlc next --execute # Run classifier and execute action +sdlc next --for <feature> # Classify for specific feature +sdlc history # Show action history + +# ============================================ +# QUERIES +# ============================================ +sdlc query blocked # List all blocked items +sdlc query ready # List items ready for work +sdlc query needs-approval # List items awaiting approval +sdlc query in-progress # List items in progress +``` + +### CLI Output Format + +```bash +$ sdlc next --for auth + +┌─────────────────────────────────────────────────────────────────┐ +│ 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 │ +│ Task: Implement JWT token validation │ +│ Output: .sdlc/features/auth/tasks.md (updated) │ +│ │ +│ Context provided to command: │ +│ - Task specification │ +│ - Required patterns: error-handling │ +│ - Related files: internal/auth/jwt.go │ +└─────────────────────────────────────────────────────────────────┘ + +Run with --execute to execute this action. +``` + +--- + +## Command Specifications + +Every command MUST: +1. Read context from SDLC structure +2. Produce a concrete artifact +3. Write artifact to the correct location +4. Update manifest/state +5. Return structured output + +### Command Catalog + +#### Phase: Planning + +| Command | Input | Output Location | Output Type | +|---------|-------|-----------------|-------------| +| `/plan-roadmap` | Version, goals | `.sdlc/roadmap/{version}/roadmap.md` | Roadmap document | +| `/spec-feature` | Feature slug | `.sdlc/features/{slug}/spec.md` | Specification | +| `/design-feature` | Feature slug | `.sdlc/features/{slug}/design.md` | Design document | +| `/breakdown-feature` | Feature slug | `.sdlc/features/{slug}/tasks.md` | Task breakdown | +| `/create-qa-plan` | Feature slug | `.sdlc/features/{slug}/qa-plan.md` | QA verification plan | + +#### Phase: Implementation + +| Command | Input | Output Location | Output Type | +|---------|-------|-----------------|-------------| +| `/create-branch` | Feature slug | `.sdlc/branches/{branch}.yaml` | Branch tracking | +| `/implement-task` | Feature, task ID | `.sdlc/features/{slug}/tasks.md` | Task completion | +| `/implement-feature` | Feature slug | `.sdlc/features/{slug}/tasks.md` | All tasks (orchestrated) | + +#### Phase: Quality + +| Command | Input | Output Location | Output Type | +|---------|-------|-----------------|-------------| +| `/review-feature` | Feature slug | `.sdlc/features/{slug}/review.md` | Review findings | +| `/fix-review-issues` | Feature slug | `.sdlc/features/{slug}/review.md` | Updated findings | +| `/audit-feature` | Feature slug | `.sdlc/features/{slug}/audit.md` | Audit findings | +| `/remediate-audit` | Feature slug | `.sdlc/features/{slug}/audit.md` | Updated findings | +| `/run-qa` | Feature slug | `.sdlc/features/{slug}/qa-results.md` | QA results | +| `/fix-qa-failures` | Feature slug | `.sdlc/features/{slug}/qa-results.md` | Updated results | + +#### Phase: Release + +| Command | Input | Output Location | Output Type | +|---------|-------|-----------------|-------------| +| `/merge-feature` | Feature slug | Git merge + manifest update | Merged branch | +| `/archive-feature` | Feature slug | `.sdlc/archives/{slug}/` | Archived feature | + +#### Patterns + +| Command | Input | Output Location | Output Type | +|---------|-------|-----------------|-------------| +| `/identify-pattern` | Pattern description | `.sdlc/patterns/{slug}/pattern.md` | Pattern draft | +| `/elevate-pattern` | Pattern slug | `.sdlc/patterns/{slug}/pattern.md` | Elevated pattern | +| `/add-pattern-examples` | Pattern slug | `.sdlc/patterns/{slug}/examples.md` | Code examples | +| `/find-pattern-violations` | Pattern slug | `.sdlc/patterns/{slug}/violations.md` | Violation list | +| `/fix-pattern-violations` | Pattern slug | `.sdlc/patterns/{slug}/adoption.md` | Adoption progress | + +#### Orchestration + +| Command | Input | Output Location | Output Type | +|---------|-------|-----------------|-------------| +| `/next` | Optional: feature | Classifier output | Next action | +| `/deliver` | Feature slug | Orchestrates full delivery | Full feature delivery | + +--- + +## Document Specifications + +### spec.md (Feature Specification) + +```markdown +--- +feature: auth +version: 1 +status: approved +approved_by: user +approved_at: 2024-01-10T14:00:00Z +--- + +# Feature: User Authentication System + +## Overview +[2-3 sentence summary of what this feature does] + +## Problem Statement +[What problem does this solve? Why is it needed?] + +## User Stories +- As a [user type], I want to [action] so that [benefit] +- ... + +## Acceptance Criteria +- [ ] AC1: Users can register with email and password +- [ ] AC2: Users can log in with valid credentials +- [ ] AC3: Invalid credentials return appropriate error +- [ ] AC4: Sessions expire after 24 hours +- ... + +## Non-Functional Requirements +- Performance: Login < 200ms p99 +- Security: Passwords hashed with bcrypt, cost 12 +- Availability: 99.9% uptime + +## Out of Scope +- Social login (future feature) +- MFA (future feature) + +## Dependencies +- Database provisioning (complete) +- Error handling pattern (in progress) + +## Open Questions +[Any unresolved questions - must be empty before approval] +``` + +### tasks.md (Task Breakdown) + +```markdown +--- +feature: auth +version: 1 +total_tasks: 8 +completed: 5 +in_progress: 1 +blocked: 0 +pending: 2 +--- + +# Task Breakdown: User Authentication + +## Summary +| Status | Count | +|--------|-------| +| ✓ Complete | 5 | +| → In Progress | 1 | +| ○ Pending | 2 | +| ✗ Blocked | 0 | + +## Tasks + +### task-001: Create User domain model ✓ +- **Status:** complete +- **Completed:** 2024-01-12T10:00:00Z +- **Files:** `internal/domain/user.go` +- **Patterns:** error-handling +- **Notes:** Added validation for email format + +### task-002: Create UserRepository interface ✓ +- **Status:** complete +- **Completed:** 2024-01-12T11:00:00Z +- **Files:** `internal/port/user_repository.go` + +### task-003: Implement PostgreSQL UserRepository ✓ +- **Status:** complete +- **Completed:** 2024-01-12T14:00:00Z +- **Files:** `internal/adapter/postgres/user_repo.go` +- **Tests:** `internal/adapter/postgres/user_repo_test.go` + +### task-004: Implement JWT token validation → +- **Status:** in_progress +- **Started:** 2024-01-12T15:00:00Z +- **Spec:** | + Implement JWT token validation middleware: + - Parse Authorization header (Bearer token) + - Validate token signature using project secret + - Extract claims and attach to context + - Return 401 for invalid/expired tokens +- **Files:** + - `internal/auth/jwt.go` + - `internal/middleware/auth.go` +- **Patterns:** error-handling +- **Depends on:** task-001, task-002 + +### task-005: Implement login endpoint ○ +- **Status:** pending +- **Spec:** | + POST /api/v1/auth/login + - Accept email + password + - Validate credentials + - Return JWT token on success + - Return 401 on failure +- **Depends on:** task-003, task-004 + +### task-006: Implement register endpoint ○ +- **Status:** pending +- **Spec:** | + POST /api/v1/auth/register + - Accept email + password + - Validate email format and password strength + - Hash password with bcrypt + - Create user record + - Return 201 on success +- **Depends on:** task-003 + +### task-007: Add authentication tests ✓ +- **Status:** complete +- **Completed:** 2024-01-12T16:00:00Z +- **Files:** `internal/auth/*_test.go` + +### task-008: Update API documentation ✓ +- **Status:** complete +- **Completed:** 2024-01-12T16:30:00Z +- **Files:** `docs/api/auth.md` +``` + +### qa-plan.md (QA Verification Plan) + +```markdown +--- +feature: auth +version: 1 +checkpoints: 12 +categories: + - functional: 6 + - security: 4 + - performance: 2 +--- + +# QA Verification Plan: User Authentication + +## Functional Tests + +### QA-001: User Registration Happy Path +- **Category:** functional +- **Priority:** P0 +- **Steps:** + 1. POST /api/v1/auth/register with valid email and password + 2. Verify response status 201 + 3. Verify response contains user ID + 4. Verify user exists in database + 5. Verify password is hashed (not plaintext) +- **Expected:** User created successfully + +### QA-002: User Registration - Invalid Email +- **Category:** functional +- **Priority:** P0 +- **Steps:** + 1. POST /api/v1/auth/register with invalid email format + 2. Verify response status 400 + 3. Verify error message indicates email format issue +- **Expected:** Validation error returned + +### QA-003: User Login Happy Path +- **Category:** functional +- **Priority:** P0 +- **Steps:** + 1. Create test user + 2. POST /api/v1/auth/login with correct credentials + 3. Verify response status 200 + 4. Verify response contains valid JWT token + 5. Verify token contains correct claims +- **Expected:** Valid JWT returned + +### QA-004: User Login - Invalid Password +- **Category:** functional +- **Priority:** P0 +- **Steps:** + 1. Create test user + 2. POST /api/v1/auth/login with wrong password + 3. Verify response status 401 + 4. Verify error message is generic (not revealing which field is wrong) +- **Expected:** Authentication error, no information leakage + +### QA-005: Protected Endpoint - Valid Token +- **Category:** functional +- **Priority:** P0 +- **Steps:** + 1. Obtain valid JWT token + 2. Access protected endpoint with token in Authorization header + 3. Verify response status 200 + 4. Verify user context is available +- **Expected:** Access granted + +### QA-006: Protected Endpoint - Expired Token +- **Category:** functional +- **Priority:** P0 +- **Steps:** + 1. Generate expired JWT token + 2. Access protected endpoint with expired token + 3. Verify response status 401 + 4. Verify error indicates token expiration +- **Expected:** Access denied with clear message + +## Security Tests + +### QA-007: SQL Injection Prevention +- **Category:** security +- **Priority:** P0 +- **Steps:** + 1. Attempt login with SQL injection payloads + 2. Verify no SQL errors exposed + 3. Verify queries are parameterized (code review) +- **Expected:** All inputs sanitized + +### QA-008: Password Storage Security +- **Category:** security +- **Priority:** P0 +- **Steps:** + 1. Create user + 2. Examine database record + 3. Verify password is bcrypt hashed + 4. Verify cost factor >= 12 +- **Expected:** Passwords securely hashed + +### QA-009: Token Secret Security +- **Category:** security +- **Priority:** P0 +- **Steps:** + 1. Verify JWT secret loaded from environment + 2. Verify secret not logged + 3. Verify secret not in code +- **Expected:** Secret properly managed + +### QA-010: Brute Force Protection +- **Category:** security +- **Priority:** P1 +- **Steps:** + 1. Attempt 10 failed logins rapidly + 2. Verify rate limiting kicks in + 3. Verify appropriate error returned +- **Expected:** Rate limiting active + +## Performance Tests + +### QA-011: Login Latency +- **Category:** performance +- **Priority:** P1 +- **Steps:** + 1. Run 100 login requests + 2. Measure p50, p90, p99 latency +- **Expected:** p99 < 200ms + +### QA-012: Token Validation Latency +- **Category:** performance +- **Priority:** P1 +- **Steps:** + 1. Run 1000 authenticated requests + 2. Measure validation overhead +- **Expected:** < 5ms overhead +``` + +### review.md (Code Review Findings) + +```markdown +--- +feature: auth +version: 1 +reviewed_at: 2024-01-13T10:00:00Z +status: needs_fix +findings: + blocker: 0 + critical: 1 + warning: 3 + suggestion: 2 +--- + +# Code Review: User Authentication + +## Summary + +| Severity | Count | Status | +|----------|-------|--------| +| Blocker | 0 | - | +| Critical | 1 | ✗ Open | +| Warning | 3 | ✗ Open | +| Suggestion | 2 | ✗ Open | + +**Verdict:** NEEDS_FIX + +## Findings + +### CRITICAL-001: Missing error context in JWT validation +- **File:** `internal/auth/jwt.go:45` +- **Status:** open +- **Issue:** Error returned without context, making debugging difficult +- **Code:** + ```go + // Current + return nil, err + + // Should be + return nil, fmt.Errorf("validate jwt token: %w", err) + ``` +- **Fix Applied:** [ ] + +### WARNING-001: Inconsistent error handling in login handler +- **File:** `internal/handlers/auth.go:67` +- **Status:** open +- **Issue:** Uses `api.WriteError` instead of `api.WriteUnauthorized` +- **Pattern:** error-handling +- **Fix Applied:** [ ] + +### WARNING-002: Missing table-driven tests for token validation +- **File:** `internal/auth/jwt_test.go` +- **Status:** open +- **Issue:** Tests cover happy path but miss edge cases +- **Missing cases:** + - Empty token + - Malformed token + - Token with invalid signature + - Token with missing claims +- **Fix Applied:** [ ] + +### WARNING-003: Hardcoded token expiration +- **File:** `internal/auth/jwt.go:23` +- **Status:** open +- **Issue:** Token expiration hardcoded to 24h, should be configurable +- **Fix Applied:** [ ] + +### SUGGESTION-001: Consider extracting JWT config +- **File:** `internal/auth/jwt.go` +- **Status:** open +- **Issue:** JWT configuration inline, could be cleaner as config struct +- **Fix Applied:** [ ] + +### SUGGESTION-002: Add structured logging for auth events +- **File:** `internal/handlers/auth.go` +- **Status:** open +- **Issue:** No logging for login attempts (success/failure) +- **Fix Applied:** [ ] + +## What's Good + +- Clean separation between domain and handler layers +- Proper use of context for request scoping +- Good test coverage for happy paths +- Consistent API response format +``` + +### audit.md (Post-Implementation Audit) + +```markdown +--- +feature: auth +version: 1 +audited_at: 2024-01-13T14:00:00Z +status: needs_remediation +categories: + - pattern_compliance + - security + - performance + - documentation +findings: + critical: 0 + high: 2 + medium: 3 + low: 1 +--- + +# Audit Report: User Authentication + +## Summary + +| Category | Status | Issues | +|----------|--------|--------| +| Pattern Compliance | ⚠️ | 2 issues | +| Security | ✓ | 0 issues | +| Performance | ⚠️ | 1 issue | +| Documentation | ⚠️ | 2 issues | + +**Verdict:** NEEDS_REMEDIATION + +## Pattern Compliance + +### HIGH-001: Logging pattern not followed +- **Pattern:** logging-standards +- **Files:** + - `internal/handlers/auth.go` - No structured logging + - `internal/auth/jwt.go` - No trace ID propagation +- **Required:** All handlers must log with trace ID and structured fields +- **Remediation:** Apply logging pattern to all auth files +- **Remediated:** [ ] + +### HIGH-002: Error handling inconsistent +- **Pattern:** error-handling +- **Files:** + - `internal/auth/jwt.go:45` - Missing error context + - `internal/auth/jwt.go:67` - Missing error context +- **Required:** All errors wrapped with context +- **Remediation:** Add error wrapping +- **Remediated:** [ ] + +## Security + +No issues found. ✓ + +## Performance + +### MEDIUM-001: Database connection not pooled efficiently +- **File:** `internal/adapter/postgres/user_repo.go` +- **Issue:** Each query creates new connection +- **Impact:** Higher latency under load +- **Remediation:** Use connection pool from injected DB +- **Remediated:** [ ] + +## Documentation + +### MEDIUM-002: API documentation incomplete +- **File:** `docs/api/auth.md` +- **Missing:** + - Error response examples + - Rate limiting documentation +- **Remediation:** Add missing sections +- **Remediated:** [ ] + +### LOW-001: Code comments sparse +- **Files:** Various +- **Issue:** Complex logic not commented +- **Remediation:** Add comments for non-obvious code +- **Remediated:** [ ] + +## Recommendations + +1. Run `/fix-pattern-violations logging-standards` on auth module +2. Run `/fix-pattern-violations error-handling` on auth module +3. Update API documentation +``` + +### pattern.md (Pattern Specification) + +```markdown +--- +pattern: error-handling +version: 2 +status: elevated +created: 2024-01-05T09:00:00Z +elevated: 2024-01-10T14:00:00Z +adoption: + total_files: 45 + compliant: 38 + violations: 7 +--- + +# Pattern: Error Handling + +## Overview + +All errors in {{PROJECT_NAME}} must be wrapped with context, use sentinel errors for known conditions, and propagate correctly through the hexagonal architecture. + +## The Pattern + +### 1. Error Wrapping + +Always wrap errors with context using `fmt.Errorf` with `%w`: + +```go +// ✓ GOOD +result, err := repo.FindByID(ctx, id) +if err != nil { + return nil, fmt.Errorf("find user by id %s: %w", id, err) +} + +// ✗ BAD +result, err := repo.FindByID(ctx, id) +if err != nil { + return nil, err // Lost context! +} +``` + +### 2. Sentinel Errors + +Define sentinel errors in `internal/domain/errors.go`: + +```go +var ( + ErrNotFound = errors.New("not found") + ErrAlreadyExists = errors.New("already exists") + ErrInvalidInput = errors.New("invalid input") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") +) +``` + +### 3. Error Checking + +Use `errors.Is` for sentinel errors: + +```go +if errors.Is(err, domain.ErrNotFound) { + api.WriteNotFound(w, "user not found") + return +} +``` + +### 4. Handler Error Responses + +Use appropriate response helpers: + +```go +// ✓ GOOD +api.WriteNotFound(w, "user not found") +api.WriteBadRequest(w, "invalid email format") +api.WriteUnauthorized(w, "invalid credentials") + +// ✗ BAD +api.WriteError(w, http.StatusNotFound, "NOT_FOUND", "user not found") +``` + +### 5. Never Swallow Errors + +```go +// ✗ NEVER DO THIS +result, _ := something() + +// ✓ ALWAYS HANDLE +result, err := something() +if err != nil { + // handle it +} +``` + +## Detection Rules + +```go +// Violations to detect: +// 1. Bare error returns: `return nil, err` without wrapping +// 2. Ignored errors: `_, _ = something()` or `_ = err` +// 3. Direct status codes: `api.WriteError(w, 404, ...)` instead of helpers +// 4. errors.New in handlers (should use domain errors) +``` + +## Adoption Status + +| Status | Files | +|--------|-------| +| Compliant | 38 | +| Violations | 7 | +| **Total** | 45 | + +### Known Violations + +See `.sdlc/patterns/error-handling/violations.md` for current violation list. +``` + +--- + +## Driver / Orchestrator + +The driver is a simple loop that uses the classifier to determine and execute the next action. + +### Driver Pseudocode + +```python +def drive(target: str = None, max_iterations: int = 100): + """ + Main driver loop that advances SDLC state. + + Args: + target: Optional specific feature/pattern to focus on + max_iterations: Safety limit to prevent infinite loops + """ + iteration = 0 + + while iteration < max_iterations: + iteration += 1 + + # 1. Run classifier + classification = sdlc_cli("next", target) + + # 2. Check for terminal states + if classification.action == "IDLE": + log("No actionable work found") + break + + if classification.action == "BLOCKED": + log(f"Blocked: {classification.message}") + prompt_user_for_resolution() + continue + + if classification.action == "AWAIT_APPROVAL": + log(f"Awaiting approval: {classification.message}") + approval = prompt_user_for_approval() + if approval: + sdlc_cli("artifact", "approve", classification.artifact) + continue + + # 3. Execute action + log(f"Executing: {classification.command}") + result = execute_claude_command(classification.command) + + # 4. Verify output was produced + if not verify_output_exists(classification.output_path): + log(f"ERROR: Expected output not found at {classification.output_path}") + prompt_user_for_intervention() + continue + + # 5. Update state + sdlc_cli("state", "record-action", classification.action) + + # 6. Handle transitions + if classification.action == "TRANSITION": + sdlc_cli("feature", "transition", target, classification.transition_to) + + # 7. Loop continues with next classification + + if iteration >= max_iterations: + log("WARNING: Max iterations reached, stopping driver") +``` + +### Claude Command: `/next` + +```markdown +--- +description: Determine and optionally execute the next SDLC action +argument-hint: <feature-slug> or "all" for global next action +allowed-tools: Bash, Read, Write, Edit +--- + +Determine what needs to happen next for: $ARGUMENTS + +## Instructions + +1. Run the classifier: + ```bash + sdlc next --for $ARGUMENTS + ``` + +2. Parse the classifier output + +3. If action is AWAIT_APPROVAL: + - Show what needs approval + - Ask user to approve/reject + - Record decision with `sdlc artifact approve/reject` + +4. If action is BLOCKED: + - Show the blocker + - Ask user how to resolve + +5. If action is a command: + - Show the recommended command + - Ask user: "Execute this? (y/n)" + - If yes, execute and verify output + +6. After execution: + - Verify output file exists at expected location + - Update state: `sdlc state record-action <action>` + - Run classifier again to show next step + +## Output Format + +```markdown +## SDLC Status: {feature} + +**Phase:** {current_phase} +**Progress:** {tasks_complete}/{tasks_total} tasks + +### Next Action: {action} + +**Command:** `{command}` +**Output:** `{output_path}` +**Context:** +{context_summary} + +--- + +Execute this action? (y/n) +``` +``` + +--- + +## Enterprise Considerations + +### Audit Trail + +Every action is logged: + +```yaml +# .sdlc/state.yaml (history section) +history: + - timestamp: "2024-01-15T10:30:00Z" + action: "IMPLEMENT_TASK" + feature: "auth" + task: "task-004" + actor: "claude" + result: "success" + output: ".sdlc/features/auth/tasks.md" + + - timestamp: "2024-01-15T10:35:00Z" + action: "TRANSITION" + feature: "auth" + from_phase: "implementation" + to_phase: "review" + actor: "classifier" +``` + +### Compliance Gates + +Define required approvals: + +```yaml +# .sdlc/config.yaml +compliance: + spec_approval: + required: true + approvers: ["user", "tech-lead"] + + design_approval: + required: true + approvers: ["user", "architect"] + + merge_approval: + required: true + approvers: ["user"] + checks: + - "all_tasks_complete" + - "review_passed" + - "audit_passed" + - "qa_passed" +``` + +### Metrics + +Track development velocity: + +```yaml +# .sdlc/metrics.yaml +features: + auth: + cycle_time_days: 5 + phases: + draft_to_specified: 0.5 + specified_to_planned: 1.0 + planned_to_ready: 0.5 + ready_to_implementation: 3.0 + implementation_to_review: 0.5 + review_to_audit: 0.25 + audit_to_qa: 0.25 + qa_to_merge: 0.25 + review_iterations: 2 + audit_iterations: 1 + qa_iterations: 1 +``` + +### Rollback Support + +Every phase transition is reversible: + +```bash +# Rollback feature to previous phase +sdlc feature rollback auth --to implementation + +# This: +# 1. Updates manifest phase +# 2. Clears artifacts for phases after target +# 3. Records rollback in history +``` + +--- + +## rdev API Surface + +Every classifier rule maps to an API-addressable state. The API provides full visibility and control over the SDLC workflow. + +### Core Principle + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ API-First SDLC │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ External Driver (CI, UI, Bot) │ +│ │ │ +│ ▼ │ +│ ┌─────────────┐ │ +│ │ rdev API │◀──── GET /sdlc/next "What needs to happen?" │ +│ │ │◀──── GET /sdlc/blocked "What's stuck?" │ +│ │ │◀──── POST /sdlc/resolve "Here's the answer" │ +│ │ │◀──── POST /sdlc/execute "Do it" │ +│ │ │◀──── POST /sdlc/commit "Save it" │ +│ └─────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Classifier │───▶│ Executor │───▶│ Git │ │ +│ │ Engine │ │ (Claude) │ │ (.sdlc/) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### API Endpoints + +#### State & Classification + +``` +GET /projects/{project}/sdlc/state +``` +Returns current SDLC state for the project. + +```json +{ + "project": "my-project", + "current_roadmap": "v1.0", + "active_features": [ + { + "slug": "auth", + "phase": "implementation", + "branch": "feature/auth", + "tasks_complete": 5, + "tasks_total": 8, + "blocked": false + } + ], + "active_patterns": [], + "blocked_items": [] +} +``` + +--- + +``` +GET /projects/{project}/sdlc/next +GET /projects/{project}/sdlc/next?feature={slug} +``` +Run classifier and return the next required action. + +```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": "Implement JWT token validation middleware...", + "patterns_required": ["error-handling"], + "related_files": ["internal/auth/jwt.go"] + } + }, + "execute_url": "/projects/my-project/sdlc/execute", + "can_auto_execute": true +} +``` + +--- + +``` +GET /projects/{project}/sdlc/blocked +``` +Returns all blocked items with resolution instructions. + +```json +{ + "blocked": [ + { + "type": "feature", + "slug": "auth", + "rule_id": "spec-needs-approval", + "blocked_reason": "Specification requires user approval", + "resolution": { + "action": "APPROVE_ARTIFACT", + "instruction": "Review the specification and approve or request changes", + "artifact_path": ".sdlc/features/auth/spec.md", + "resolve_url": "/projects/my-project/sdlc/features/auth/artifacts/spec/approve", + "options": ["approve", "reject", "request_changes"] + } + }, + { + "type": "feature", + "slug": "payment", + "rule_id": "blocked-dependency", + "blocked_reason": "Depends on feature 'auth' which is not complete", + "resolution": { + "action": "COMPLETE_DEPENDENCY", + "instruction": "Complete the 'auth' feature first", + "dependency": "auth", + "dependency_phase": "implementation", + "unblock_url": null + } + }, + { + "type": "feature", + "slug": "notifications", + "rule_id": "design-decision-needed", + "blocked_reason": "Design decision required: Choose notification transport", + "resolution": { + "action": "ANSWER_QUESTION", + "instruction": "Answer the design question in the design document", + "question": "Which notification transport should we use?", + "options": ["websocket", "sse", "polling"], + "answer_path": ".sdlc/features/notifications/design.md", + "answer_section": "## Open Questions", + "resolve_url": "/projects/my-project/sdlc/features/notifications/resolve" + } + } + ] +} +``` + +#### Execution + +``` +POST /projects/{project}/sdlc/execute +``` +Execute the next classified action. Returns execution result and new state. + +Request: +```json +{ + "feature": "auth", + "action": "IMPLEMENT_TASK", + "task_id": "task-004", + "auto_commit": true +} +``` + +Response: +```json +{ + "execution": { + "status": "success", + "action": "IMPLEMENT_TASK", + "duration_ms": 45000, + "output_path": ".sdlc/features/auth/tasks.md", + "files_modified": [ + "internal/auth/jwt.go", + "internal/middleware/auth.go", + ".sdlc/features/auth/tasks.md" + ], + "commit": { + "sha": "abc123", + "message": "feat(auth): implement JWT token validation [task-004]" + } + }, + "next": { + "action": "IMPLEMENT_TASK", + "task_id": "task-005", + "summary": "Implement login endpoint" + } +} +``` + +--- + +``` +POST /projects/{project}/sdlc/resolve +``` +Resolve a blocked item by providing the required input. + +Request: +```json +{ + "feature": "notifications", + "resolution_type": "ANSWER_QUESTION", + "question_id": "notification-transport", + "answer": "websocket", + "rationale": "WebSocket provides real-time bidirectional communication needed for instant notifications", + "auto_commit": true +} +``` + +Response: +```json +{ + "resolution": { + "status": "resolved", + "file_updated": ".sdlc/features/notifications/design.md", + "commit": { + "sha": "def456", + "message": "docs(notifications): resolve design decision - use websocket transport" + } + }, + "unblocked": true, + "next": { + "action": "CREATE_TASKS", + "command": "/breakdown-feature notifications" + } +} +``` + +#### Feature Management + +``` +POST /projects/{project}/sdlc/features +``` +Create a new feature. + +Request: +```json +{ + "slug": "auth", + "title": "User Authentication System", + "roadmap_ref": "v1.0/M2", + "description": "Implement user registration, login, and session management" +} +``` + +--- + +``` +GET /projects/{project}/sdlc/features/{slug} +``` +Get feature details including all artifacts and current state. + +```json +{ + "slug": "auth", + "title": "User Authentication System", + "phase": "implementation", + "branch": "feature/auth", + "created": "2024-01-10T09:00:00Z", + "artifacts": { + "spec": { + "status": "approved", + "path": ".sdlc/features/auth/spec.md", + "url": "/projects/my-project/files/.sdlc/features/auth/spec.md" + }, + "design": { + "status": "approved", + "path": ".sdlc/features/auth/design.md" + }, + "tasks": { + "status": "in_progress", + "path": ".sdlc/features/auth/tasks.md", + "summary": { + "total": 8, + "complete": 5, + "in_progress": 1, + "pending": 2 + } + }, + "qa_plan": { + "status": "approved", + "checkpoints": 12 + }, + "review": { + "status": "pending" + }, + "audit": { + "status": "pending" + }, + "qa_results": { + "status": "pending" + } + }, + "next_action": { + "action": "IMPLEMENT_TASK", + "task_id": "task-004" + } +} +``` + +--- + +``` +POST /projects/{project}/sdlc/features/{slug}/transition +``` +Manually transition a feature to a new phase (with validation). + +Request: +```json +{ + "to_phase": "review", + "force": false +} +``` + +Response: +```json +{ + "transition": { + "from": "implementation", + "to": "review", + "status": "success" + } +} +``` + +Or if validation fails: +```json +{ + "transition": { + "from": "implementation", + "to": "review", + "status": "blocked", + "reason": "Cannot transition: 2 tasks still pending", + "blockers": [ + {"task_id": "task-007", "title": "Add authentication tests"}, + {"task_id": "task-008", "title": "Update API documentation"} + ] + } +} +``` + +#### Artifact Management + +``` +GET /projects/{project}/sdlc/features/{slug}/artifacts/{type} +``` +Get artifact content. + +```json +{ + "artifact": { + "type": "spec", + "status": "approved", + "path": ".sdlc/features/auth/spec.md", + "content": "# Feature: User Authentication...", + "approved_by": "user", + "approved_at": "2024-01-10T14:00:00Z" + } +} +``` + +--- + +``` +POST /projects/{project}/sdlc/features/{slug}/artifacts/{type} +``` +Create or update an artifact. + +Request: +```json +{ + "content": "# Feature: User Authentication\n\n## Overview\n...", + "auto_commit": true, + "commit_message": "docs(auth): update specification" +} +``` + +--- + +``` +POST /projects/{project}/sdlc/features/{slug}/artifacts/{type}/approve +``` +Approve an artifact. + +Request: +```json +{ + "approved_by": "user", + "comments": "Looks good, approved for implementation" +} +``` + +--- + +``` +POST /projects/{project}/sdlc/features/{slug}/artifacts/{type}/reject +``` +Reject an artifact with feedback. + +Request: +```json +{ + "rejected_by": "user", + "reason": "Missing acceptance criteria for error cases", + "required_changes": [ + "Add AC for invalid email format", + "Add AC for password requirements" + ] +} +``` + +#### Task Management + +``` +GET /projects/{project}/sdlc/features/{slug}/tasks +``` +List all tasks for a feature. + +```json +{ + "tasks": [ + { + "id": "task-001", + "title": "Create User domain model", + "status": "complete", + "completed_at": "2024-01-12T10:00:00Z" + }, + { + "id": "task-004", + "title": "Implement JWT token validation", + "status": "in_progress", + "started_at": "2024-01-12T15:00:00Z", + "spec": "Implement JWT token validation middleware..." + }, + { + "id": "task-005", + "title": "Implement login endpoint", + "status": "pending", + "depends_on": ["task-003", "task-004"] + } + ] +} +``` + +--- + +``` +POST /projects/{project}/sdlc/features/{slug}/tasks/{task_id}/start +POST /projects/{project}/sdlc/features/{slug}/tasks/{task_id}/complete +POST /projects/{project}/sdlc/features/{slug}/tasks/{task_id}/block +``` +Update task status. + +#### Pattern Management + +``` +GET /projects/{project}/sdlc/patterns +GET /projects/{project}/sdlc/patterns/{slug} +POST /projects/{project}/sdlc/patterns +POST /projects/{project}/sdlc/patterns/{slug}/violations +POST /projects/{project}/sdlc/patterns/{slug}/adopt +``` + +#### Git Operations + +``` +POST /projects/{project}/sdlc/commit +``` +Commit current SDLC changes. + +Request: +```json +{ + "message": "docs(auth): complete task-004 specification", + "files": [".sdlc/features/auth/tasks.md"] +} +``` + +--- + +``` +POST /projects/{project}/sdlc/branches +``` +Create a feature branch. + +Request: +```json +{ + "feature": "auth", + "base": "main" +} +``` + +--- + +``` +POST /projects/{project}/sdlc/branches/{branch}/sync +``` +Sync feature branch with main. + +--- + +``` +POST /projects/{project}/sdlc/merge +``` +Merge a completed feature. + +Request: +```json +{ + "feature": "auth", + "strategy": "squash", + "delete_branch": true +} +``` + +### Classifier Rule → API Mapping + +Every classifier rule has a corresponding API interaction: + +| Rule ID | Action | API Endpoint | Method | +|---------|--------|--------------|--------| +| `needs-spec` | CREATE_SPEC | `/sdlc/execute` | POST | +| `spec-needs-approval` | AWAIT_APPROVAL | `/sdlc/features/{slug}/artifacts/spec/approve` | POST | +| `needs-design` | CREATE_DESIGN | `/sdlc/execute` | POST | +| `design-decision-needed` | ANSWER_QUESTION | `/sdlc/resolve` | POST | +| `needs-tasks` | CREATE_TASKS | `/sdlc/execute` | POST | +| `needs-qa-plan` | CREATE_QA_PLAN | `/sdlc/execute` | POST | +| `needs-branch` | CREATE_BRANCH | `/sdlc/branches` | POST | +| `blocked-dependency` | BLOCKED | `/sdlc/blocked` | GET (info only) | +| `implement-next-task` | IMPLEMENT_TASK | `/sdlc/execute` | POST | +| `implementation-complete` | TRANSITION | `/sdlc/features/{slug}/transition` | POST | +| `needs-review` | REVIEW_CODE | `/sdlc/execute` | POST | +| `review-has-issues` | FIX_ISSUES | `/sdlc/execute` | POST | +| `review-passed` | TRANSITION | `/sdlc/features/{slug}/transition` | POST | +| `needs-audit` | AUDIT_CODE | `/sdlc/execute` | POST | +| `audit-has-issues` | REMEDIATE | `/sdlc/execute` | POST | +| `needs-qa` | RUN_QA | `/sdlc/execute` | POST | +| `qa-has-failures` | FIX_FAILURES | `/sdlc/execute` | POST | +| `needs-merge` | MERGE | `/sdlc/merge` | POST | + +### Blocking Resolution Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Blocking Resolution Flow │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. GET /sdlc/blocked │ +│ ↓ │ +│ Returns: { │ +│ "blocked_reason": "Design decision required", │ +│ "question": "Which auth provider?", │ +│ "options": ["jwt", "oauth", "session"], │ +│ "answer_path": ".sdlc/features/auth/design.md", │ +│ "resolve_url": "/sdlc/features/auth/resolve" │ +│ } │ +│ │ +│ 2. POST /sdlc/features/auth/resolve │ +│ Body: { │ +│ "question_id": "auth-provider", │ +│ "answer": "jwt", │ +│ "rationale": "Stateless, works well with microservices" │ +│ } │ +│ ↓ │ +│ System: Writes answer to design.md │ +│ │ +│ 3. POST /sdlc/commit │ +│ Body: { "message": "docs(auth): decide on JWT provider" } │ +│ ↓ │ +│ System: Commits the change │ +│ │ +│ 4. GET /sdlc/next?feature=auth │ +│ ↓ │ +│ Returns: Next action (no longer blocked) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Driver Loop (External Orchestrator) + +```python +# Example driver that uses the API +import requests + +BASE = "https://rdev.example.com/projects/my-project" + +def drive_feature(feature_slug): + while True: + # 1. Check for blockers + blocked = requests.get(f"{BASE}/sdlc/blocked").json() + feature_blockers = [b for b in blocked["blocked"] if b["slug"] == feature_slug] + + if feature_blockers: + blocker = feature_blockers[0] + if blocker["resolution"]["action"] == "ANSWER_QUESTION": + # Present to user, get answer + answer = prompt_user(blocker["resolution"]["question"], + blocker["resolution"]["options"]) + # Resolve it + requests.post(blocker["resolution"]["resolve_url"], json={ + "answer": answer, + "auto_commit": True + }) + continue + elif blocker["resolution"]["action"] == "APPROVE_ARTIFACT": + # Show artifact, get approval + artifact = requests.get(f"{BASE}/sdlc/features/{feature_slug}/artifacts/{blocker['artifact']}").json() + if prompt_user_approval(artifact["content"]): + requests.post(blocker["resolution"]["resolve_url"], json={ + "approved_by": "user" + }) + continue + else: + print(f"Blocked: {blocker['blocked_reason']}") + break + + # 2. Get next action + next_action = requests.get(f"{BASE}/sdlc/next?feature={feature_slug}").json() + + if next_action["classification"]["action"] == "IDLE": + print("Feature complete!") + break + + # 3. Execute it + result = requests.post(f"{BASE}/sdlc/execute", json={ + "feature": feature_slug, + "action": next_action["classification"]["action"], + "auto_commit": True + }).json() + + print(f"Executed: {result['execution']['action']}") +``` + +## Implementation Phases + +### Phase 1: Core API Endpoints +1. `/sdlc/state` - GET current state +2. `/sdlc/features` - CRUD features +3. `/sdlc/features/{slug}/artifacts` - CRUD artifacts +4. `/sdlc/commit` - Commit changes + +### Phase 2: Classifier & Execution +1. `/sdlc/next` - Run classifier +2. `/sdlc/execute` - Execute actions +3. `/sdlc/blocked` - List blockers +4. `/sdlc/resolve` - Resolve blockers + +### Phase 3: Git Integration +1. `/sdlc/branches` - Branch management +2. `/sdlc/merge` - Merge features +3. Branch protection integration + +### Phase 4: Task Execution +1. Claude command integration +2. Task status tracking +3. File modification tracking + +### Phase 5: Full Workflow +1. Review/Audit/QA flows +2. Pattern management +3. Metrics and reporting + +--- + +## Success Criteria + +1. **Deterministic:** Same state always produces same next action +2. **Auditable:** Every action recorded with timestamp and actor +3. **Recoverable:** Can rollback any phase, can resume from any state +4. **Complete:** No work can be "lost" - everything has a place +5. **Enforceable:** Cannot merge without completing all gates +6. **Adaptive:** Rules can be customized per project +7. **Observable:** Current state always queryable +8. **Integrated:** Works seamlessly with Claude commands + +--- + +## Appendix: File Templates + +### .sdlc/config.yaml + +```yaml +version: 1 +project: + name: "{{PROJECT_NAME}}" + type: "composable-monorepo" + +branches: + main: "main" + feature_prefix: "feature/" + +phases: + enabled: + - draft + - specified + - planned + - ready + - implementation + - review + - audit + - qa + - merge + - released + + required_artifacts: + specified: ["spec"] + planned: ["spec", "design", "tasks", "qa_plan"] + review: ["review"] + audit: ["audit"] + qa: ["qa_results"] + +compliance: + require_approvals: true + require_branch: true + require_qa: true + +patterns: + auto_enforce: + - error-handling + - logging-standards +``` + +### .sdlc/state.yaml (Initial) + +```yaml +version: 1 +project: + name: "{{PROJECT_NAME}}" + current_roadmap: null + +active_work: + features: [] + patterns: [] + audits: [] + +blocked: [] + +last_updated: null +last_action: null +last_actor: null + +history: [] +``` diff --git a/examples/dashboard-app/README.md b/examples/dashboard-app/README.md new file mode 100644 index 0000000..f58ab36 --- /dev/null +++ b/examples/dashboard-app/README.md @@ -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) diff --git a/internal/adapter/templates/provider.go b/internal/adapter/templates/provider.go index cc9f249..93862e8 100644 --- a/internal/adapter/templates/provider.go +++ b/internal/adapter/templates/provider.go @@ -73,6 +73,13 @@ var availableComponentTemplates = []port.ComponentTemplateInfo{ DefaultPort: 5173, DestDir: "apps", }, + { + Type: "app-nextjs", + Description: "Next.js 14 dashboard with App Router and design system", + Stack: "nextjs", + DefaultPort: 3000, + DestDir: "apps", + }, { Type: "cli", Description: "Go CLI tool using Cobra", diff --git a/internal/adapter/templates/templates/components/app-nextjs/.eslintrc.json b/internal/adapter/templates/templates/components/app-nextjs/.eslintrc.json new file mode 100644 index 0000000..12568f6 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["next/core-web-vitals"], + "rules": { + "@next/next/no-html-link-for-pages": "off" + } +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl new file mode 100644 index 0000000..2a18624 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl @@ -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 diff --git a/internal/adapter/templates/templates/components/app-nextjs/Dockerfile.tmpl b/internal/adapter/templates/templates/components/app-nextjs/Dockerfile.tmpl new file mode 100644 index 0000000..da2526b --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/Dockerfile.tmpl @@ -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"] diff --git a/internal/adapter/templates/templates/components/app-nextjs/component.yaml.tmpl b/internal/adapter/templates/templates/components/app-nextjs/component.yaml.tmpl new file mode 100644 index 0000000..bb7004b --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/component.yaml.tmpl @@ -0,0 +1,6 @@ +name: {{COMPONENT_NAME}} +type: app +port: {{PORT}} +path: apps/{{COMPONENT_NAME}} +stack: nextjs +dependencies: [] diff --git a/internal/adapter/templates/templates/components/app-nextjs/next.config.ts.tmpl b/internal/adapter/templates/templates/components/app-nextjs/next.config.ts.tmpl new file mode 100644 index 0000000..be67f05 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/next.config.ts.tmpl @@ -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; diff --git a/internal/adapter/templates/templates/components/app-nextjs/package.json.tmpl b/internal/adapter/templates/templates/components/app-nextjs/package.json.tmpl new file mode 100644 index 0000000..7b02871 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/package.json.tmpl @@ -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" + } +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/postcss.config.js b/internal/adapter/templates/templates/components/app-nextjs/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/internal/adapter/templates/templates/components/app-nextjs/public/favicon.svg b/internal/adapter/templates/templates/components/app-nextjs/public/favicon.svg new file mode 100644 index 0000000..4bd29e1 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/public/favicon.svg @@ -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> diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/actions/example.ts.tmpl b/internal/adapter/templates/templates/components/app-nextjs/src/actions/example.ts.tmpl new file mode 100644 index 0000000..f9aee15 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/actions/example.ts.tmpl @@ -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, + }; +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/dashboard/page.tsx.tmpl b/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/dashboard/page.tsx.tmpl new file mode 100644 index 0000000..143bc62 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/dashboard/page.tsx.tmpl @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/dashboard/settings/page.tsx.tmpl b/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/dashboard/settings/page.tsx.tmpl new file mode 100644 index 0000000..535890c --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/dashboard/settings/page.tsx.tmpl @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/layout.tsx.tmpl b/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/layout.tsx.tmpl new file mode 100644 index 0000000..57957e2 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/app/(dashboard)/layout.tsx.tmpl @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/app/globals.css b/internal/adapter/templates/templates/components/app-nextjs/src/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/app/layout.tsx.tmpl b/internal/adapter/templates/templates/components/app-nextjs/src/app/layout.tsx.tmpl new file mode 100644 index 0000000..4c213d4 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/app/layout.tsx.tmpl @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/app/login/page.tsx.tmpl b/internal/adapter/templates/templates/components/app-nextjs/src/app/login/page.tsx.tmpl new file mode 100644 index 0000000..c9dbfa9 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/app/login/page.tsx.tmpl @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/app/page.tsx.tmpl b/internal/adapter/templates/templates/components/app-nextjs/src/app/page.tsx.tmpl new file mode 100644 index 0000000..d7ea913 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/app/page.tsx.tmpl @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + // Redirect to dashboard by default + redirect('/dashboard'); +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/components/providers.tsx.tmpl b/internal/adapter/templates/templates/components/app-nextjs/src/components/providers.tsx.tmpl new file mode 100644 index 0000000..4d8bc9a --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/components/providers.tsx.tmpl @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/components/app-nextjs/src/lib/utils.ts b/internal/adapter/templates/templates/components/app-nextjs/src/lib/utils.ts new file mode 100644 index 0000000..20f035a --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/src/lib/utils.ts @@ -0,0 +1,2 @@ +// Re-export cn from @{{PROJECT_NAME}}/ui for convenience +export { cn } from '@{{PROJECT_NAME}}/ui'; diff --git a/internal/adapter/templates/templates/components/app-nextjs/tailwind.config.ts b/internal/adapter/templates/templates/components/app-nextjs/tailwind.config.ts new file mode 100644 index 0000000..71b87dd --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/tailwind.config.ts @@ -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; diff --git a/internal/adapter/templates/templates/components/app-nextjs/tsconfig.json b/internal/adapter/templates/templates/components/app-nextjs/tsconfig.json new file mode 100644 index 0000000..7b28589 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-nextjs/tsconfig.json @@ -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"] +} diff --git a/internal/adapter/templates/templates/components/app-react/package.json.tmpl b/internal/adapter/templates/templates/components/app-react/package.json.tmpl index dd636c9..398220a 100644 --- a/internal/adapter/templates/templates/components/app-react/package.json.tmpl +++ b/internal/adapter/templates/templates/components/app-react/package.json.tmpl @@ -12,8 +12,11 @@ }, "dependencies": { "@{{PROJECT_NAME}}/logger": "workspace:*", + "@{{PROJECT_NAME}}/ui": "workspace:*", + "@{{PROJECT_NAME}}/layout": "workspace:*", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1" }, "devDependencies": { "@types/react": "^18.3.3", diff --git a/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl index 00543bb..44b3f0c 100644 --- a/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl +++ b/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl @@ -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() { return ( - <div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800"> - <div className="container mx-auto px-4 py-16"> - <div className="text-center"> - <h1 className="text-5xl font-bold text-white mb-6"> - {{COMPONENT_NAME}} - </h1> - <p className="text-xl text-slate-300 mb-8 max-w-2xl mx-auto"> - Welcome to your React app. This is part of the{' '} - <code className="bg-slate-700 px-2 py-1 rounded">{{PROJECT_NAME}}</code>{' '} - monorepo. - </p> - <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 - </code> - </p> - <div className="flex gap-4 justify-center"> - <a - href="https://react.dev" - className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" - > - React Docs - </a> - <a - href="https://vitejs.dev" - className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition" - > - Vite Docs - </a> - <a - href="{{GIT_URL}}" - className="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition" - > - View Source - </a> - </div> + <DashboardShell + sidebar={ + <Sidebar + logo={ + <span className="font-semibold text-lg">{{PROJECT_NAME}}</span> + } + items={navItems} + footer={ + <div className="text-sm text-[var(--text-muted)]"> + v0.0.1 + </div> + } + /> + } + header={ + <Header + title="Dashboard" + showSearch + searchPlaceholder="Search..." + /> + } + > + <div className="space-y-6"> + {/* Welcome card */} + <Card> + <CardHeader> + <CardTitle>Welcome to {{COMPONENT_NAME}}</CardTitle> + <CardDescription> + This is part of the{' '} + <code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded text-sm"> + {{PROJECT_NAME}} + </code>{' '} + monorepo, using the shared UI library and layout components. + </CardDescription> + </CardHeader> + <CardContent> + <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> + + {/* 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> + </DashboardShell> ); } diff --git a/internal/adapter/templates/templates/components/app-react/src/index.css b/internal/adapter/templates/templates/components/app-react/src/index.css deleted file mode 100644 index 17df0e7..0000000 --- a/internal/adapter/templates/templates/components/app-react/src/index.css +++ /dev/null @@ -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; -} diff --git a/internal/adapter/templates/templates/components/app-react/src/index.css.tmpl b/internal/adapter/templates/templates/components/app-react/src/index.css.tmpl new file mode 100644 index 0000000..420e201 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/index.css.tmpl @@ -0,0 +1,6 @@ +/* Import design system tokens */ +@import '@{{PROJECT_NAME}}/ui/styles'; + +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/example.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/example.go.tmpl new file mode 100644 index 0000000..98afbce --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/example.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl index f87ba6f..c0c5f92 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl @@ -12,10 +12,14 @@ func RegisterRoutes(application *app.App) { // Initialize handlers healthHandler := handlers.NewHealth(logger) + exampleHandler := handlers.NewExample(logger) // Register API routes application.Route("/api/v1", func(r app.Router) { 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)) }) } diff --git a/internal/adapter/templates/templates/skeleton/.claude/guides/feature-development.md.tmpl b/internal/adapter/templates/templates/skeleton/.claude/guides/feature-development.md.tmpl new file mode 100644 index 0000000..2cd3cac --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/guides/feature-development.md.tmpl @@ -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) diff --git a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl index 66ae883..563b2d3 100644 --- a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl +++ b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl @@ -7,6 +7,7 @@ | If you need to... | Read this | |-------------------|-----------| | **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) | ## Quick Reference diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/api-client/package.json.tmpl new file mode 100644 index 0000000..843427e --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/api-client/package.json.tmpl @@ -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" + } +} diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/src/client.ts b/internal/adapter/templates/templates/skeleton/packages/api-client/src/client.ts new file mode 100644 index 0000000..dedf866 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/api-client/src/client.ts @@ -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), + }; +} diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/api-client/src/index.ts new file mode 100644 index 0000000..560ecd2 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/api-client/src/index.ts @@ -0,0 +1,3 @@ +export * from './client'; +// Note: schema.d.ts is generated by running `pnpm generate` +// export type { paths, components, operations } from './schema'; diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/tsconfig.json b/internal/adapter/templates/templates/skeleton/packages/api-client/tsconfig.json new file mode 100644 index 0000000..163abe7 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/api-client/tsconfig.json @@ -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"] +} diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/auth/package.json.tmpl new file mode 100644 index 0000000..a0ff0e5 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/auth/package.json.tmpl @@ -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" + } +} diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx b/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx new file mode 100644 index 0000000..878dbeb --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx @@ -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; +} diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/src/ProtectedRoute.tsx b/internal/adapter/templates/templates/skeleton/packages/auth/src/ProtectedRoute.tsx new file mode 100644 index 0000000..e3ac4bb --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/auth/src/ProtectedRoute.tsx @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/auth/src/index.ts new file mode 100644 index 0000000..a51948c --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/auth/src/index.ts @@ -0,0 +1,3 @@ +export { AuthProvider, useAuth, type AuthContextValue } from './AuthProvider'; +export { ProtectedRoute } from './ProtectedRoute'; +export type { User, AuthState, LoginCredentials } from './types'; diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/src/types.ts b/internal/adapter/templates/templates/skeleton/packages/auth/src/types.ts new file mode 100644 index 0000000..fde8653 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/auth/src/types.ts @@ -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; +} diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/tsconfig.json b/internal/adapter/templates/templates/skeleton/packages/auth/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/auth/tsconfig.json @@ -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"] +} diff --git a/internal/adapter/templates/templates/skeleton/packages/layout/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/layout/package.json.tmpl new file mode 100644 index 0000000..080e1fe --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/layout/package.json.tmpl @@ -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" + } +} diff --git a/internal/adapter/templates/templates/skeleton/packages/layout/src/DashboardShell.tsx b/internal/adapter/templates/templates/skeleton/packages/layout/src/DashboardShell.tsx new file mode 100644 index 0000000..74ff658 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/layout/src/DashboardShell.tsx @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/skeleton/packages/layout/src/Header.tsx b/internal/adapter/templates/templates/skeleton/packages/layout/src/Header.tsx new file mode 100644 index 0000000..fb572d3 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/layout/src/Header.tsx @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/skeleton/packages/layout/src/Sidebar.tsx b/internal/adapter/templates/templates/skeleton/packages/layout/src/Sidebar.tsx new file mode 100644 index 0000000..5f73c0b --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/layout/src/Sidebar.tsx @@ -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> + ); +} diff --git a/internal/adapter/templates/templates/skeleton/packages/layout/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/layout/src/index.ts new file mode 100644 index 0000000..0ada1d1 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/layout/src/index.ts @@ -0,0 +1,3 @@ +export { DashboardShell, type DashboardShellProps } from './DashboardShell'; +export { Sidebar, type SidebarProps, type NavItem } from './Sidebar'; +export { Header, type HeaderProps } from './Header'; diff --git a/internal/adapter/templates/templates/skeleton/packages/layout/tsconfig.json b/internal/adapter/templates/templates/skeleton/packages/layout/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/layout/tsconfig.json @@ -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"] +} diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/ui/package.json.tmpl new file mode 100644 index 0000000..cef6a54 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/package.json.tmpl @@ -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" + } +} diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Badge.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Badge.tsx new file mode 100644 index 0000000..0d6f8bb --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Badge.tsx @@ -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 }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Button.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Button.tsx new file mode 100644 index 0000000..30b5a09 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Button.tsx @@ -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 }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Card.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Card.tsx new file mode 100644 index 0000000..3ad5bec --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Card.tsx @@ -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 }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Checkbox.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Checkbox.tsx new file mode 100644 index 0000000..4b532a0 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Checkbox.tsx @@ -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 }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Dialog.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Dialog.tsx new file mode 100644 index 0000000..9c54fba --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Dialog.tsx @@ -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, +}; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Input.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Input.tsx new file mode 100644 index 0000000..da40d12 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Input.tsx @@ -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 }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Label.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Label.tsx new file mode 100644 index 0000000..b7f78b2 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Label.tsx @@ -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 }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Select.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b030b4d --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Select.tsx @@ -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, +}; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Table.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..32da31b --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Table.tsx @@ -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, +}; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts new file mode 100644 index 0000000..c3aa2af --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts @@ -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'; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/styles/tokens.css b/internal/adapter/templates/templates/skeleton/packages/ui/src/styles/tokens.css new file mode 100644 index 0000000..59961fe --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/styles/tokens.css @@ -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); +} diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/utils/cn.ts b/internal/adapter/templates/templates/skeleton/packages/ui/src/utils/cn.ts new file mode 100644 index 0000000..163cfab --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/utils/cn.ts @@ -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)); +} diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/tsconfig.json b/internal/adapter/templates/templates/skeleton/packages/ui/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/tsconfig.json @@ -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"] +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/README.md b/internal/adapter/templates/templates/skeleton/pkg/README.md index d15ee7f..ecab83d 100644 --- a/internal/adapter/templates/templates/skeleton/pkg/README.md +++ b/internal/adapter/templates/templates/skeleton/pkg/README.md @@ -6,10 +6,11 @@ This directory contains shared Go packages used across all components in the mon | 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 | | `httpcontext` | Type-safe context key helpers for request-scoped data | | `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 | | `httpvalidation` | Struct validation wrapper around go-playground/validator | | `logging` | slog-based structured logging with context integration | @@ -26,6 +27,7 @@ import ( "net/http" "{{GO_MODULE}}/pkg/app" + "{{GO_MODULE}}/pkg/httperror" "{{GO_MODULE}}/pkg/httpresponse" ) @@ -33,14 +35,36 @@ func main() { // Create application with default middleware and health endpoints svc := app.New("my-service", app.WithDefaultPort(8080)) - // Register routes - svc.GET("/hello", func(w http.ResponseWriter, r *http.Request) { - httpresponse.OK(w, r, map[string]string{"message": "Hello, World!"}) - }) + // Register routes using Wrap pattern for error-returning handlers + svc.GET("/hello", app.Wrap(getHello)) + svc.POST("/users", app.Wrap(createUser)) // Start server (blocks until shutdown signal) 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 @@ -49,8 +73,10 @@ func main() { Service bootstrapper that provides: - 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 -- Health check endpoints (`/health`, `/ready`) ```go app := app.New("my-service", @@ -58,13 +84,13 @@ app := app.New("my-service", app.WithLogger(customLogger), ) -// Register routes -app.GET("/users/{id}", getUser) -app.POST("/users", createUser) +// Register routes using Wrap pattern +app.GET("/users/{id}", app.Wrap(getUser)) +app.POST("/users", app.Wrap(createUser)) // Group routes app.Route("/api/v1", func(r chi.Router) { - r.Get("/users", listUsers) + r.Get("/users", app.Wrap(listUsers)) }) // Register shutdown hooks @@ -75,6 +101,46 @@ app.OnShutdown(func(ctx context.Context) error { 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 Configuration loading from environment variables with Viper. @@ -153,6 +219,58 @@ Does NOT retry on: - HTTP 4xx client errors (except 429) - 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 Standard response envelope for API responses. diff --git a/internal/adapter/templates/templates/skeleton/pkg/app/bind.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/app/bind.go.tmpl new file mode 100644 index 0000000..ace6d99 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/app/bind.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/app/handler.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/app/handler.go.tmpl new file mode 100644 index 0000000..6606562 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/app/handler.go.tmpl @@ -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) + } + }) + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/app/health.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/app/health.go.tmpl new file mode 100644 index 0000000..8674842 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/app/health.go.tmpl @@ -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 + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/auth/apikey.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/auth/apikey.go.tmpl new file mode 100644 index 0000000..22b45aa --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/auth/apikey.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/auth/auth.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/auth/auth.go.tmpl new file mode 100644 index 0000000..544c39d --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/auth/auth.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/auth/context.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/auth/context.go.tmpl new file mode 100644 index 0000000..50c93a7 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/auth/context.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/auth/jwt.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/auth/jwt.go.tmpl new file mode 100644 index 0000000..273823a --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/auth/jwt.go.tmpl @@ -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) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/auth/middleware.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/auth/middleware.go.tmpl new file mode 100644 index 0000000..e206160 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/auth/middleware.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/httperror/error.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/httperror/error.go.tmpl new file mode 100644 index 0000000..17d0f1b --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/httperror/error.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/scripts/generate-client.sh.tmpl b/internal/adapter/templates/templates/skeleton/scripts/generate-client.sh.tmpl new file mode 100644 index 0000000..2926523 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/scripts/generate-client.sh.tmpl @@ -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" diff --git a/internal/handlers/components.go b/internal/handlers/components.go index 2ddb780..eac6a74 100644 --- a/internal/handlers/components.go +++ b/internal/handlers/components.go @@ -10,14 +10,16 @@ import ( "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // ComponentsHandler handles component management endpoints. type ComponentsHandler struct { - service port.ComponentService - logger *slog.Logger + service port.ComponentService + operationService *service.OperationService + logger *slog.Logger } // 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} } +// 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. func (h *ComponentsHandler) Mount(r api.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 } + // 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{ Type: req.Type, Name: req.Name, @@ -95,6 +114,11 @@ func (h *ComponentsHandler) Add(w http.ResponseWriter, r *http.Request) { Port: req.Port, }) 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 switch { case errors.Is(err, domain.ErrInvalidComponentType): @@ -112,14 +136,31 @@ func (h *ComponentsHandler) Add(w http.ResponseWriter, r *http.Request) { return } - api.WriteCreated(w, r, ComponentResponse{ - Type: string(component.Type), - Name: component.Name, - Path: component.Path, - Port: component.Port, - Template: component.Template, - Dependencies: component.Dependencies, - }) + if h.operationService != nil && operationID != "" { + if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{ + "path": component.Path, + "port": component.Port, + }); opErr != nil { + h.logger.Error("failed to record operation completion", "error", opErr, "operation_id", operationID) + } + } + + 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. diff --git a/internal/handlers/components_operations_test.go b/internal/handlers/components_operations_test.go new file mode 100644 index 0000000..c82f4da --- /dev/null +++ b/internal/handlers/components_operations_test.go @@ -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") + } + } +} diff --git a/internal/handlers/mock_operation_test.go b/internal/handlers/mock_operation_test.go new file mode 100644 index 0000000..7de2cd7 --- /dev/null +++ b/internal/handlers/mock_operation_test.go @@ -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) +} diff --git a/internal/handlers/project_management.go b/internal/handlers/project_management.go index 3e1aa28..763bc7c 100644 --- a/internal/handlers/project_management.go +++ b/internal/handlers/project_management.go @@ -15,8 +15,9 @@ import ( // ProjectManagementHandler handles project lifecycle operations. type ProjectManagementHandler struct { - infraService *service.ProjectInfraService - logger *slog.Logger + infraService *service.ProjectInfraService + operationService *service.OperationService + logger *slog.Logger } // 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. func (h *ProjectManagementHandler) Mount(r api.Router) { r.Route("/project", func(r chi.Router) { @@ -75,6 +84,15 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request 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{ Name: req.Name, Description: req.Description, @@ -82,6 +100,11 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request Template: req.Template, }) 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 if errors.Is(err, domain.ErrInvalidProjectName) { api.WriteBadRequest(w, r, err.Error()) @@ -93,7 +116,16 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request 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, "name": result.Name, "description": result.Description, @@ -107,7 +139,11 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request "domain": result.Domain, "url": result.URL, "next_steps": result.NextSteps, - }) + } + if operationID != "" { + resp["operation_id"] = operationID + } + api.WriteCreated(w, r, resp) } // List returns all projects. diff --git a/internal/handlers/project_management_test.go b/internal/handlers/project_management_test.go index 68fe6c5..33a34f6 100644 --- a/internal/handlers/project_management_test.go +++ b/internal/handlers/project_management_test.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/service" ) 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") } } + +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) + } + } + } +} diff --git a/internal/handlers/woodpecker_webhook.go b/internal/handlers/woodpecker_webhook.go index cab76d9..f4e218f 100644 --- a/internal/handlers/woodpecker_webhook.go +++ b/internal/handlers/woodpecker_webhook.go @@ -15,14 +15,16 @@ import ( "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/pkg/api" ) // WoodpeckerWebhookHandler handles webhooks from Woodpecker CI. type WoodpeckerWebhookHandler struct { - deployer port.Deployer - dns port.DNSProvider - logger *slog.Logger + deployer port.Deployer + dns port.DNSProvider + operationService *service.OperationService + logger *slog.Logger // Config 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. func (h *WoodpeckerWebhookHandler) Mount(r api.Router) { // 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. // Component deployments are created by createInitialComponentDeployment // 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, ) - api.WriteSuccess(w, r, map[string]any{ + resp := map[string]any{ "status": "success", "project": projectName, "image": imageTag, "commit": payload.Build.Commit, "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. diff --git a/internal/handlers/woodpecker_webhook_test.go b/internal/handlers/woodpecker_webhook_test.go index 46e6a2a..987fbd6 100644 --- a/internal/handlers/woodpecker_webhook_test.go +++ b/internal/handlers/woodpecker_webhook_test.go @@ -4,7 +4,15 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" "testing" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/service" ) func TestVerifySignature_ValidSignature(t *testing.T) { @@ -83,3 +91,195 @@ func TestVerifySignature_TamperedBody(t *testing.T) { 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()) + } +} diff --git a/pkg/api/bind.go b/pkg/api/bind.go new file mode 100644 index 0000000..2115781 --- /dev/null +++ b/pkg/api/bind.go @@ -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 +} diff --git a/pkg/api/error.go b/pkg/api/error.go new file mode 100644 index 0000000..a848cfb --- /dev/null +++ b/pkg/api/error.go @@ -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 +} diff --git a/pkg/api/error_test.go b/pkg/api/error_test.go new file mode 100644 index 0000000..bcad837 --- /dev/null +++ b/pkg/api/error_test.go @@ -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) + } + }) + } +} diff --git a/pkg/api/handler.go b/pkg/api/handler.go new file mode 100644 index 0000000..cbf90a4 --- /dev/null +++ b/pkg/api/handler.go @@ -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) + } + }) + } +} diff --git a/pkg/api/handler_test.go b/pkg/api/handler_test.go new file mode 100644 index 0000000..5233df2 --- /dev/null +++ b/pkg/api/handler_test.go @@ -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) + } + }) +} diff --git a/pkg/api/health.go b/pkg/api/health.go new file mode 100644 index 0000000..4ac0be3 --- /dev/null +++ b/pkg/api/health.go @@ -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 + } +} diff --git a/pkg/api/health_test.go b/pkg/api/health_test.go new file mode 100644 index 0000000..c406539 --- /dev/null +++ b/pkg/api/health_test.go @@ -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") + } + }) +} diff --git a/pkg/api/openapi.go b/pkg/api/openapi.go index fab98e1..a341eb2 100644 --- a/pkg/api/openapi.go +++ b/pkg/api/openapi.go @@ -22,13 +22,21 @@ type OpenAPIServer struct { 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. type OpenAPISpec struct { - OpenAPI string `json:"openapi"` - Info OpenAPIInfo `json:"info"` - Servers []OpenAPIServer `json:"servers,omitempty"` - Paths map[string]map[string]interface{} `json:"paths"` - Tags []OpenAPITag `json:"tags,omitempty"` + OpenAPI string `json:"openapi"` + Info OpenAPIInfo `json:"info"` + Servers []OpenAPIServer `json:"servers,omitempty"` + Paths map[string]map[string]any `json:"paths"` + Tags []OpenAPITag `json:"tags,omitempty"` + Components *OpenAPIComponents `json:"components,omitempty"` + Security []map[string][]string `json:"security,omitempty"` mu sync.RWMutex } @@ -47,7 +55,7 @@ func NewOpenAPISpec(title, version string) *OpenAPISpec { Title: title, 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. // 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() defer s.mu.Unlock() if s.Paths[path] == nil { - s.Paths[path] = make(map[string]interface{}) + s.Paths[path] = make(map[string]any) } s.Paths[path][method] = operation 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. func (s *OpenAPISpec) JSON() ([]byte, error) { s.mu.RLock() diff --git a/pkg/api/openapi_params.go b/pkg/api/openapi_params.go new file mode 100644 index 0000000..6812ebc --- /dev/null +++ b/pkg/api/openapi_params.go @@ -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()), + } +} diff --git a/pkg/api/openapi_schema.go b/pkg/api/openapi_schema.go new file mode 100644 index 0000000..a8745d2 --- /dev/null +++ b/pkg/api/openapi_schema.go @@ -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")) +}