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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:46:51 -07:00

20 KiB

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

export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai"
export RDEV_API_KEY="<your-api-key>"

Project Structure

Your composable monorepo should have:

my-project/
├── pkg/                         # Shared Go packages
│   ├── app/                     # Service bootstrapper
│   │   ├── app.go              # NewApp, Run, RegisterRoutes
│   │   ├── handler.go          # Wrap pattern
│   │   ├── bind.go             # Bind + BindAndValidate
│   │   └── health.go           # Health probes
│   ├── httperror/              # Typed HTTP errors
│   ├── httpresponse/           # JSON response envelope
│   ├── httpvalidation/         # go-playground/validator
│   └── auth/                   # JWT + API key validation
├── packages/                    # Shared TS packages
│   ├── ui/                     # Design system components
│   ├── layout/                 # DashboardShell, Sidebar
│   ├── auth/                   # AuthProvider, useAuth
│   ├── logger/                 # Structured logging
│   └── api-client/             # Generated TypeScript client
├── services/                    # Go backend services
│   └── api/
│       ├── cmd/server/main.go
│       └── internal/api/routes.go
└── apps/                        # Frontend applications
    └── dashboard/
        └── src/app/             # Next.js App Router

Step 1: Feature Planning

Create Feature Branch

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

## 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/:

// 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:

// 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

// 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

// 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:

./scripts/generate-client.sh

This updates packages/api-client/src/schema.d.ts with typed endpoints.

3.2 Create Profile Page

// apps/dashboard/src/app/(dashboard)/profile/page.tsx
import { ProfileForm } from '@/components/profile-form';
import { Card, CardContent, CardHeader, CardTitle } from '@my-project/ui';

export default function ProfilePage() {
    return (
        <div className="p-6">
            <Card>
                <CardHeader>
                    <CardTitle>Profile Settings</CardTitle>
                </CardHeader>
                <CardContent>
                    <ProfileForm />
                </CardContent>
            </Card>
        </div>
    );
}

3.3 Create Profile Form with Auth Hook

// apps/dashboard/src/components/profile-form.tsx
'use client';

import { useState } from 'react';
import { useAuth } from '@my-project/auth';
import { Button, Input, Label } from '@my-project/ui';
import { updateProfile } from '@/actions/profile';

export function ProfileForm() {
    const { user, refreshUser } = useAuth();
    const [name, setName] = useState(user?.name ?? '');
    const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? '');
    const [isSubmitting, setIsSubmitting] = useState(false);
    const [error, setError] = useState<string | null>(null);

    if (!user) {
        return <div>Loading...</div>;
    }

    async function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        setIsSubmitting(true);
        setError(null);

        try {
            const result = await updateProfile({ name, avatar_url: avatarUrl });
            if (result.success) {
                await refreshUser();
            } else {
                setError(result.error ?? 'Failed to update profile');
            }
        } catch (err) {
            setError('An unexpected error occurred');
        } finally {
            setIsSubmitting(false);
        }
    }

    return (
        <form onSubmit={handleSubmit} className="space-y-4">
            <div className="space-y-2">
                <Label htmlFor="email">Email</Label>
                <Input
                    id="email"
                    type="email"
                    value={user.email}
                    disabled
                    className="bg-surface-50"
                />
            </div>

            <div className="space-y-2">
                <Label htmlFor="name">Display Name</Label>
                <Input
                    id="name"
                    value={name}
                    onChange={(e) => setName(e.target.value)}
                    placeholder="Enter your name"
                    required
                />
            </div>

            <div className="space-y-2">
                <Label htmlFor="avatar">Avatar URL</Label>
                <Input
                    id="avatar"
                    type="url"
                    value={avatarUrl}
                    onChange={(e) => setAvatarUrl(e.target.value)}
                    placeholder="https://example.com/avatar.png"
                />
            </div>

            {error && (
                <div className="text-sm text-error">{error}</div>
            )}

            <Button type="submit" disabled={isSubmitting}>
                {isSubmitting ? 'Saving...' : 'Save Changes'}
            </Button>
        </form>
    );
}

3.4 Create Server Action

// apps/dashboard/src/actions/profile.ts
'use server';

import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';

interface UpdateProfileInput {
    name: string;
    avatar_url?: string;
}

interface UpdateProfileResult {
    success: boolean;
    error?: string;
}

export async function updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult> {
    const token = cookies().get('auth_token')?.value;

    if (!token) {
        return { success: false, error: 'Not authenticated' };
    }

    const response = await fetch(`${process.env.API_URL}/api/users/me`, {
        method: 'PUT',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${token}`,
        },
        body: JSON.stringify(input),
    });

    if (!response.ok) {
        const error = await response.json();
        return {
            success: false,
            error: error.error?.message ?? 'Failed to update profile',
        };
    }

    revalidatePath('/profile');
    return { success: true };
}
// 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

// 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

# 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:

# 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

# 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

# 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

# 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

# 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

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:

# 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

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

var req RequestType
if err := app.BindAndValidate(r, &req); err != nil {
    return err // Validation errors become 400 with details
}

HTTPError Sentinels

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

user, ok := auth.GetUser(r.Context())
if !ok {
    return httperror.Unauthorized("not authenticated")
}

React Auth Hook

const { user, isLoading, login, logout } = useAuth();

Design System Components

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:

# 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.