diff --git a/docs/plans/2026-01-29-sdk-generation-impl.md b/docs/plans/2026-01-29-sdk-generation-impl.md new file mode 100644 index 0000000..8e263f0 --- /dev/null +++ b/docs/plans/2026-01-29-sdk-generation-impl.md @@ -0,0 +1,1861 @@ +# SDK Generation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Generate typed API clients from OpenAPI specs for TypeScript, Python, Go, and PHP with breaking change detection. + +**Architecture:** Hybrid generator approach - native tools where available (openapi-typescript-codegen, openapi-python-client, oapi-codegen), Docker fallback for others (openapi-generator). Detection flow: config → common paths → Laravel Scramble. Breaking changes via oasdiff library. + +**Tech Stack:** Go, oasdiff, kin-openapi, embedded templates, exec for native generators, Docker for fallback + +--- + +### Task 1: Create SDK Package Structure + +**Files:** +- Create: `pkg/sdk/sdk.go` +- Create: `pkg/sdk/go.mod` + +**Step 1: Create go.mod for sdk package** + +```go +module github.com/host-uk/core/pkg/sdk + +go 1.25 + +require ( + github.com/getkin/kin-openapi v0.128.0 + github.com/tufin/oasdiff v1.10.25 + gopkg.in/yaml.v3 v3.0.1 +) +``` + +**Step 2: Create sdk.go with types and config** + +```go +// Package sdk provides OpenAPI SDK generation and diff capabilities. +package sdk + +import ( + "context" + "fmt" +) + +// Config holds SDK generation configuration from .core/release.yaml. +type Config struct { + // Spec is the path to the OpenAPI spec file (auto-detected if empty). + Spec string `yaml:"spec,omitempty"` + // Languages to generate SDKs for. + Languages []string `yaml:"languages,omitempty"` + // Output directory (default: sdk/). + Output string `yaml:"output,omitempty"` + // Package naming configuration. + Package PackageConfig `yaml:"package,omitempty"` + // Diff configuration for breaking change detection. + Diff DiffConfig `yaml:"diff,omitempty"` + // Publish configuration for monorepo publishing. + Publish PublishConfig `yaml:"publish,omitempty"` +} + +// PackageConfig holds package naming configuration. +type PackageConfig struct { + // Name is the base package name. + Name string `yaml:"name,omitempty"` + // Version is the SDK version (supports templates like {{.Version}}). + Version string `yaml:"version,omitempty"` +} + +// DiffConfig holds breaking change detection configuration. +type DiffConfig struct { + // Enabled determines whether to run diff checks. + Enabled bool `yaml:"enabled,omitempty"` + // FailOnBreaking fails the release if breaking changes are detected. + FailOnBreaking bool `yaml:"fail_on_breaking,omitempty"` +} + +// PublishConfig holds monorepo publishing configuration. +type PublishConfig struct { + // Repo is the SDK monorepo (e.g., "myorg/sdks"). + Repo string `yaml:"repo,omitempty"` + // Path is the subdirectory for this SDK (e.g., "packages/myapi"). + Path string `yaml:"path,omitempty"` +} + +// SDK orchestrates OpenAPI SDK generation. +type SDK struct { + config *Config + projectDir string +} + +// New creates a new SDK instance. +func New(projectDir string, config *Config) *SDK { + if config == nil { + config = DefaultConfig() + } + return &SDK{ + config: config, + projectDir: projectDir, + } +} + +// DefaultConfig returns sensible defaults for SDK configuration. +func DefaultConfig() *Config { + return &Config{ + Languages: []string{"typescript", "python", "go", "php"}, + Output: "sdk", + Diff: DiffConfig{ + Enabled: true, + FailOnBreaking: false, + }, + } +} + +// Generate generates SDKs for all configured languages. +func (s *SDK) Generate(ctx context.Context) error { + return fmt.Errorf("sdk.Generate: not implemented") +} + +// GenerateLanguage generates SDK for a specific language. +func (s *SDK) GenerateLanguage(ctx context.Context, lang string) error { + return fmt.Errorf("sdk.GenerateLanguage: not implemented") +} +``` + +**Step 3: Add to go.work** + +Run: `cd /Users/snider/Code/Core && echo " ./pkg/sdk" >> go.work && go work sync` + +**Step 4: Verify it compiles** + +Run: `cd /Users/snider/Code/Core && go build ./pkg/sdk/...` +Expected: No errors + +**Step 5: Commit** + +```bash +git add pkg/sdk/ +git add go.work go.work.sum +git commit -m "feat(sdk): add SDK package structure with types + +Initial pkg/sdk setup with Config types for OpenAPI SDK generation. +Includes language selection, diff config, and publish config. + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 2: Implement OpenAPI Spec Detection + +**Files:** +- Create: `pkg/sdk/detect.go` +- Create: `pkg/sdk/detect_test.go` + +**Step 1: Write the failing test** + +```go +package sdk + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectSpec_Good_ConfigPath(t *testing.T) { + // Create temp directory with spec at configured path + tmpDir := t.TempDir() + specPath := filepath.Join(tmpDir, "api", "spec.yaml") + os.MkdirAll(filepath.Dir(specPath), 0755) + os.WriteFile(specPath, []byte("openapi: 3.0.0"), 0644) + + sdk := New(tmpDir, &Config{Spec: "api/spec.yaml"}) + got, err := sdk.DetectSpec() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != specPath { + t.Errorf("got %q, want %q", got, specPath) + } +} + +func TestDetectSpec_Good_CommonPath(t *testing.T) { + // Create temp directory with spec at common path + tmpDir := t.TempDir() + specPath := filepath.Join(tmpDir, "openapi.yaml") + os.WriteFile(specPath, []byte("openapi: 3.0.0"), 0644) + + sdk := New(tmpDir, nil) + got, err := sdk.DetectSpec() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != specPath { + t.Errorf("got %q, want %q", got, specPath) + } +} + +func TestDetectSpec_Bad_NotFound(t *testing.T) { + tmpDir := t.TempDir() + sdk := New(tmpDir, nil) + _, err := sdk.DetectSpec() + if err == nil { + t.Fatal("expected error for missing spec") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/... -run TestDetectSpec -v` +Expected: FAIL (DetectSpec not defined) + +**Step 3: Write minimal implementation** + +```go +package sdk + +import ( + "fmt" + "os" + "path/filepath" +) + +// commonSpecPaths are checked in order when no spec is configured. +var commonSpecPaths = []string{ + "api/openapi.yaml", + "api/openapi.json", + "openapi.yaml", + "openapi.json", + "docs/api.yaml", + "docs/api.json", + "swagger.yaml", + "swagger.json", +} + +// DetectSpec finds the OpenAPI spec file. +// Priority: config path → common paths → Laravel Scramble. +func (s *SDK) DetectSpec() (string, error) { + // 1. Check configured path + if s.config.Spec != "" { + specPath := filepath.Join(s.projectDir, s.config.Spec) + if _, err := os.Stat(specPath); err == nil { + return specPath, nil + } + return "", fmt.Errorf("sdk.DetectSpec: configured spec not found: %s", s.config.Spec) + } + + // 2. Check common paths + for _, p := range commonSpecPaths { + specPath := filepath.Join(s.projectDir, p) + if _, err := os.Stat(specPath); err == nil { + return specPath, nil + } + } + + // 3. Try Laravel Scramble detection + specPath, err := s.detectScramble() + if err == nil { + return specPath, nil + } + + return "", fmt.Errorf("sdk.DetectSpec: no OpenAPI spec found (checked config, common paths, Scramble)") +} + +// detectScramble checks for Laravel Scramble and exports the spec. +func (s *SDK) detectScramble() (string, error) { + composerPath := filepath.Join(s.projectDir, "composer.json") + if _, err := os.Stat(composerPath); err != nil { + return "", fmt.Errorf("no composer.json") + } + + // Check for scramble in composer.json + data, err := os.ReadFile(composerPath) + if err != nil { + return "", err + } + + // Simple check for scramble package + if !containsScramble(data) { + return "", fmt.Errorf("scramble not found in composer.json") + } + + // TODO: Run php artisan scramble:export + return "", fmt.Errorf("scramble export not implemented") +} + +// containsScramble checks if composer.json includes scramble. +func containsScramble(data []byte) bool { + return len(data) > 0 && + (contains(data, "dedoc/scramble") || contains(data, "\"scramble\"")) +} + +// contains is a simple byte slice search. +func contains(data []byte, substr string) bool { + return len(data) >= len(substr) && + string(data) != "" && + indexOf(string(data), substr) >= 0 +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/... -run TestDetectSpec -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add pkg/sdk/detect.go pkg/sdk/detect_test.go +git commit -m "feat(sdk): add OpenAPI spec detection + +Detects OpenAPI spec via: +1. Configured spec path +2. Common paths (api/openapi.yaml, openapi.yaml, etc.) +3. Laravel Scramble (stub for now) + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 3: Define Generator Interface + +**Files:** +- Create: `pkg/sdk/generators/generator.go` + +**Step 1: Create generator interface** + +```go +// Package generators provides SDK code generators for different languages. +package generators + +import ( + "context" +) + +// Options holds common generation options. +type Options struct { + // SpecPath is the path to the OpenAPI spec file. + SpecPath string + // OutputDir is where to write the generated SDK. + OutputDir string + // PackageName is the package/module name. + PackageName string + // Version is the SDK version. + Version string +} + +// Generator defines the interface for SDK generators. +type Generator interface { + // Language returns the generator's target language identifier. + Language() string + + // Generate creates SDK from OpenAPI spec. + Generate(ctx context.Context, opts Options) error + + // Available checks if generator dependencies are installed. + Available() bool + + // Install returns instructions for installing the generator. + Install() string +} + +// Registry holds available generators. +type Registry struct { + generators map[string]Generator +} + +// NewRegistry creates a registry with all available generators. +func NewRegistry() *Registry { + r := &Registry{ + generators: make(map[string]Generator), + } + // Generators will be registered in subsequent tasks + return r +} + +// Get returns a generator by language. +func (r *Registry) Get(lang string) (Generator, bool) { + g, ok := r.generators[lang] + return g, ok +} + +// Register adds a generator to the registry. +func (r *Registry) Register(g Generator) { + r.generators[g.Language()] = g +} + +// Languages returns all registered language identifiers. +func (r *Registry) Languages() []string { + langs := make([]string, 0, len(r.generators)) + for lang := range r.generators { + langs = append(langs, lang) + } + return langs +} +``` + +**Step 2: Verify it compiles** + +Run: `cd /Users/snider/Code/Core && go build ./pkg/sdk/...` +Expected: No errors + +**Step 3: Commit** + +```bash +git add pkg/sdk/generators/generator.go +git commit -m "feat(sdk): add Generator interface and Registry + +Defines the common interface for SDK generators with: +- Generate(), Available(), Install() methods +- Registry for managing multiple generators + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 4: Implement TypeScript Generator + +**Files:** +- Create: `pkg/sdk/generators/typescript.go` +- Create: `pkg/sdk/generators/typescript_test.go` + +**Step 1: Write the failing test** + +```go +package generators + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestTypeScriptGenerator_Good_Available(t *testing.T) { + g := NewTypeScriptGenerator() + // Just check it doesn't panic + _ = g.Available() + _ = g.Language() + _ = g.Install() +} + +func TestTypeScriptGenerator_Good_Generate(t *testing.T) { + // Skip if no generator available + g := NewTypeScriptGenerator() + if !g.Available() && !dockerAvailable() { + t.Skip("no TypeScript generator available (need openapi-typescript-codegen or Docker)") + } + + // Create temp spec + tmpDir := t.TempDir() + specPath := filepath.Join(tmpDir, "spec.yaml") + spec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + os.WriteFile(specPath, []byte(spec), 0644) + + outputDir := filepath.Join(tmpDir, "sdk", "typescript") + err := g.Generate(context.Background(), Options{ + SpecPath: specPath, + OutputDir: outputDir, + PackageName: "test-api", + Version: "1.0.0", + }) + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Check output exists + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + t.Error("output directory not created") + } +} + +func dockerAvailable() bool { + _, err := exec.LookPath("docker") + return err == nil +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/generators/... -run TestTypeScriptGenerator -v` +Expected: FAIL (NewTypeScriptGenerator not defined) + +**Step 3: Write implementation** + +```go +package generators + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// TypeScriptGenerator generates TypeScript SDKs using openapi-typescript-codegen. +type TypeScriptGenerator struct{} + +// NewTypeScriptGenerator creates a new TypeScript generator. +func NewTypeScriptGenerator() *TypeScriptGenerator { + return &TypeScriptGenerator{} +} + +// Language returns "typescript". +func (g *TypeScriptGenerator) Language() string { + return "typescript" +} + +// Available checks if openapi-typescript-codegen is installed. +func (g *TypeScriptGenerator) Available() bool { + _, err := exec.LookPath("openapi-typescript-codegen") + if err == nil { + return true + } + // Also check npx availability + _, err = exec.LookPath("npx") + return err == nil +} + +// Install returns installation instructions. +func (g *TypeScriptGenerator) Install() string { + return "npm install -g openapi-typescript-codegen" +} + +// Generate creates TypeScript SDK from OpenAPI spec. +func (g *TypeScriptGenerator) Generate(ctx context.Context, opts Options) error { + // Ensure output directory exists + if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { + return fmt.Errorf("typescript.Generate: failed to create output dir: %w", err) + } + + // Try native generator first + if g.nativeAvailable() { + return g.generateNative(ctx, opts) + } + + // Try npx + if g.npxAvailable() { + return g.generateNpx(ctx, opts) + } + + // Fall back to Docker + return g.generateDocker(ctx, opts) +} + +func (g *TypeScriptGenerator) nativeAvailable() bool { + _, err := exec.LookPath("openapi-typescript-codegen") + return err == nil +} + +func (g *TypeScriptGenerator) npxAvailable() bool { + _, err := exec.LookPath("npx") + return err == nil +} + +func (g *TypeScriptGenerator) generateNative(ctx context.Context, opts Options) error { + cmd := exec.CommandContext(ctx, "openapi-typescript-codegen", + "--input", opts.SpecPath, + "--output", opts.OutputDir, + "--name", opts.PackageName, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (g *TypeScriptGenerator) generateNpx(ctx context.Context, opts Options) error { + cmd := exec.CommandContext(ctx, "npx", "openapi-typescript-codegen", + "--input", opts.SpecPath, + "--output", opts.OutputDir, + "--name", opts.PackageName, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (g *TypeScriptGenerator) generateDocker(ctx context.Context, opts Options) error { + // Use openapi-generator via Docker + specDir := filepath.Dir(opts.SpecPath) + specName := filepath.Base(opts.SpecPath) + + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", + "-v", specDir+":/spec", + "-v", opts.OutputDir+":/out", + "openapitools/openapi-generator-cli", "generate", + "-i", "/spec/"+specName, + "-g", "typescript-fetch", + "-o", "/out", + "--additional-properties=npmName="+opts.PackageName, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("typescript.generateDocker: %w", err) + } + return nil +} +``` + +**Step 4: Run tests** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/generators/... -run TestTypeScriptGenerator -v` +Expected: PASS (or skip if no generator available) + +**Step 5: Commit** + +```bash +git add pkg/sdk/generators/typescript.go pkg/sdk/generators/typescript_test.go +git commit -m "feat(sdk): add TypeScript generator + +Uses openapi-typescript-codegen (native or npx) with Docker fallback. +Generates TypeScript-fetch client from OpenAPI spec. + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 5: Implement Python Generator + +**Files:** +- Create: `pkg/sdk/generators/python.go` +- Create: `pkg/sdk/generators/python_test.go` + +**Step 1: Write the failing test** + +```go +package generators + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestPythonGenerator_Good_Available(t *testing.T) { + g := NewPythonGenerator() + _ = g.Available() + _ = g.Language() + _ = g.Install() +} + +func TestPythonGenerator_Good_Generate(t *testing.T) { + g := NewPythonGenerator() + if !g.Available() && !dockerAvailable() { + t.Skip("no Python generator available") + } + + tmpDir := t.TempDir() + specPath := filepath.Join(tmpDir, "spec.yaml") + spec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + os.WriteFile(specPath, []byte(spec), 0644) + + outputDir := filepath.Join(tmpDir, "sdk", "python") + err := g.Generate(context.Background(), Options{ + SpecPath: specPath, + OutputDir: outputDir, + PackageName: "test_api", + Version: "1.0.0", + }) + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + t.Error("output directory not created") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/generators/... -run TestPythonGenerator -v` +Expected: FAIL + +**Step 3: Write implementation** + +```go +package generators + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// PythonGenerator generates Python SDKs using openapi-python-client. +type PythonGenerator struct{} + +// NewPythonGenerator creates a new Python generator. +func NewPythonGenerator() *PythonGenerator { + return &PythonGenerator{} +} + +// Language returns "python". +func (g *PythonGenerator) Language() string { + return "python" +} + +// Available checks if openapi-python-client is installed. +func (g *PythonGenerator) Available() bool { + _, err := exec.LookPath("openapi-python-client") + return err == nil +} + +// Install returns installation instructions. +func (g *PythonGenerator) Install() string { + return "pip install openapi-python-client" +} + +// Generate creates Python SDK from OpenAPI spec. +func (g *PythonGenerator) Generate(ctx context.Context, opts Options) error { + if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { + return fmt.Errorf("python.Generate: failed to create output dir: %w", err) + } + + if g.Available() { + return g.generateNative(ctx, opts) + } + return g.generateDocker(ctx, opts) +} + +func (g *PythonGenerator) generateNative(ctx context.Context, opts Options) error { + // openapi-python-client creates a directory named after the package + // We need to generate into a temp location then move + parentDir := filepath.Dir(opts.OutputDir) + + cmd := exec.CommandContext(ctx, "openapi-python-client", "generate", + "--path", opts.SpecPath, + "--output-path", opts.OutputDir, + ) + cmd.Dir = parentDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (g *PythonGenerator) generateDocker(ctx context.Context, opts Options) error { + specDir := filepath.Dir(opts.SpecPath) + specName := filepath.Base(opts.SpecPath) + + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", + "-v", specDir+":/spec", + "-v", opts.OutputDir+":/out", + "openapitools/openapi-generator-cli", "generate", + "-i", "/spec/"+specName, + "-g", "python", + "-o", "/out", + "--additional-properties=packageName="+opts.PackageName, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} +``` + +**Step 4: Run tests** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/generators/... -run TestPythonGenerator -v` +Expected: PASS (or skip) + +**Step 5: Commit** + +```bash +git add pkg/sdk/generators/python.go pkg/sdk/generators/python_test.go +git commit -m "feat(sdk): add Python generator + +Uses openapi-python-client with Docker fallback. +Generates Python client from OpenAPI spec. + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 6: Implement Go Generator + +**Files:** +- Create: `pkg/sdk/generators/go.go` +- Create: `pkg/sdk/generators/go_test.go` + +**Step 1: Write the failing test** + +```go +package generators + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestGoGenerator_Good_Available(t *testing.T) { + g := NewGoGenerator() + _ = g.Available() + _ = g.Language() + _ = g.Install() +} + +func TestGoGenerator_Good_Generate(t *testing.T) { + g := NewGoGenerator() + if !g.Available() && !dockerAvailable() { + t.Skip("no Go generator available") + } + + tmpDir := t.TempDir() + specPath := filepath.Join(tmpDir, "spec.yaml") + spec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + os.WriteFile(specPath, []byte(spec), 0644) + + outputDir := filepath.Join(tmpDir, "sdk", "go") + err := g.Generate(context.Background(), Options{ + SpecPath: specPath, + OutputDir: outputDir, + PackageName: "testapi", + Version: "1.0.0", + }) + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + t.Error("output directory not created") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/generators/... -run TestGoGenerator -v` +Expected: FAIL + +**Step 3: Write implementation** + +```go +package generators + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// GoGenerator generates Go SDKs using oapi-codegen. +type GoGenerator struct{} + +// NewGoGenerator creates a new Go generator. +func NewGoGenerator() *GoGenerator { + return &GoGenerator{} +} + +// Language returns "go". +func (g *GoGenerator) Language() string { + return "go" +} + +// Available checks if oapi-codegen is installed. +func (g *GoGenerator) Available() bool { + _, err := exec.LookPath("oapi-codegen") + return err == nil +} + +// Install returns installation instructions. +func (g *GoGenerator) Install() string { + return "go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest" +} + +// Generate creates Go SDK from OpenAPI spec. +func (g *GoGenerator) Generate(ctx context.Context, opts Options) error { + if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { + return fmt.Errorf("go.Generate: failed to create output dir: %w", err) + } + + if g.Available() { + return g.generateNative(ctx, opts) + } + return g.generateDocker(ctx, opts) +} + +func (g *GoGenerator) generateNative(ctx context.Context, opts Options) error { + outputFile := filepath.Join(opts.OutputDir, "client.go") + + cmd := exec.CommandContext(ctx, "oapi-codegen", + "-package", opts.PackageName, + "-generate", "types,client", + "-o", outputFile, + opts.SpecPath, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("go.generateNative: %w", err) + } + + // Create go.mod + goMod := fmt.Sprintf("module %s\n\ngo 1.21\n", opts.PackageName) + return os.WriteFile(filepath.Join(opts.OutputDir, "go.mod"), []byte(goMod), 0644) +} + +func (g *GoGenerator) generateDocker(ctx context.Context, opts Options) error { + specDir := filepath.Dir(opts.SpecPath) + specName := filepath.Base(opts.SpecPath) + + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", + "-v", specDir+":/spec", + "-v", opts.OutputDir+":/out", + "openapitools/openapi-generator-cli", "generate", + "-i", "/spec/"+specName, + "-g", "go", + "-o", "/out", + "--additional-properties=packageName="+opts.PackageName, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} +``` + +**Step 4: Run tests** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/generators/... -run TestGoGenerator -v` +Expected: PASS (or skip) + +**Step 5: Commit** + +```bash +git add pkg/sdk/generators/go.go pkg/sdk/generators/go_test.go +git commit -m "feat(sdk): add Go generator + +Uses oapi-codegen with Docker fallback. +Generates Go client and types from OpenAPI spec. + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 7: Implement PHP Generator + +**Files:** +- Create: `pkg/sdk/generators/php.go` +- Create: `pkg/sdk/generators/php_test.go` + +**Step 1: Write the failing test** + +```go +package generators + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestPHPGenerator_Good_Available(t *testing.T) { + g := NewPHPGenerator() + _ = g.Available() + _ = g.Language() + _ = g.Install() +} + +func TestPHPGenerator_Good_Generate(t *testing.T) { + g := NewPHPGenerator() + if !g.Available() { + t.Skip("Docker not available for PHP generator") + } + + tmpDir := t.TempDir() + specPath := filepath.Join(tmpDir, "spec.yaml") + spec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + os.WriteFile(specPath, []byte(spec), 0644) + + outputDir := filepath.Join(tmpDir, "sdk", "php") + err := g.Generate(context.Background(), Options{ + SpecPath: specPath, + OutputDir: outputDir, + PackageName: "TestApi", + Version: "1.0.0", + }) + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + t.Error("output directory not created") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/generators/... -run TestPHPGenerator -v` +Expected: FAIL + +**Step 3: Write implementation** + +```go +package generators + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// PHPGenerator generates PHP SDKs using openapi-generator (Docker). +type PHPGenerator struct{} + +// NewPHPGenerator creates a new PHP generator. +func NewPHPGenerator() *PHPGenerator { + return &PHPGenerator{} +} + +// Language returns "php". +func (g *PHPGenerator) Language() string { + return "php" +} + +// Available checks if Docker is available. +func (g *PHPGenerator) Available() bool { + _, err := exec.LookPath("docker") + return err == nil +} + +// Install returns installation instructions. +func (g *PHPGenerator) Install() string { + return "Docker is required for PHP SDK generation" +} + +// Generate creates PHP SDK from OpenAPI spec using Docker. +func (g *PHPGenerator) Generate(ctx context.Context, opts Options) error { + if !g.Available() { + return fmt.Errorf("php.Generate: Docker is required but not available") + } + + if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { + return fmt.Errorf("php.Generate: failed to create output dir: %w", err) + } + + specDir := filepath.Dir(opts.SpecPath) + specName := filepath.Base(opts.SpecPath) + + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", + "-v", specDir+":/spec", + "-v", opts.OutputDir+":/out", + "openapitools/openapi-generator-cli", "generate", + "-i", "/spec/"+specName, + "-g", "php", + "-o", "/out", + "--additional-properties=invokerPackage="+opts.PackageName, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("php.Generate: %w", err) + } + return nil +} +``` + +**Step 4: Run tests** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/generators/... -run TestPHPGenerator -v` +Expected: PASS (or skip) + +**Step 5: Commit** + +```bash +git add pkg/sdk/generators/php.go pkg/sdk/generators/php_test.go +git commit -m "feat(sdk): add PHP generator + +Uses openapi-generator via Docker. +Generates PHP client from OpenAPI spec. + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 8: Implement Breaking Change Detection + +**Files:** +- Create: `pkg/sdk/diff.go` +- Create: `pkg/sdk/diff_test.go` + +**Step 1: Write the failing test** + +```go +package sdk + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiff_Good_NoBreaking(t *testing.T) { + tmpDir := t.TempDir() + + baseSpec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + revSpec := `openapi: "3.0.0" +info: + title: Test API + version: "1.1.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK + /status: + get: + operationId: getStatus + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + os.WriteFile(basePath, []byte(baseSpec), 0644) + os.WriteFile(revPath, []byte(revSpec), 0644) + + result, err := Diff(basePath, revPath) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + if result.Breaking { + t.Error("expected no breaking changes for adding endpoint") + } +} + +func TestDiff_Good_Breaking(t *testing.T) { + tmpDir := t.TempDir() + + baseSpec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK + /users: + get: + operationId: getUsers + responses: + "200": + description: OK +` + revSpec := `openapi: "3.0.0" +info: + title: Test API + version: "2.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + os.WriteFile(basePath, []byte(baseSpec), 0644) + os.WriteFile(revPath, []byte(revSpec), 0644) + + result, err := Diff(basePath, revPath) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + if !result.Breaking { + t.Error("expected breaking change for removed endpoint") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/... -run TestDiff -v` +Expected: FAIL (Diff not defined) + +**Step 3: Add oasdiff dependency** + +Run: `cd /Users/snider/Code/Core/pkg/sdk && go get github.com/tufin/oasdiff@latest github.com/getkin/kin-openapi@latest` + +**Step 4: Write implementation** + +```go +package sdk + +import ( + "fmt" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/tufin/oasdiff/checker" + "github.com/tufin/oasdiff/diff" + "github.com/tufin/oasdiff/load" +) + +// DiffResult holds the result of comparing two OpenAPI specs. +type DiffResult struct { + // Breaking is true if breaking changes were detected. + Breaking bool + // Changes is the list of breaking changes. + Changes []string + // Summary is a human-readable summary. + Summary string +} + +// Diff compares two OpenAPI specs and detects breaking changes. +func Diff(basePath, revisionPath string) (*DiffResult, error) { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + + // Load specs + baseSpec, err := load.NewSpecInfo(loader, load.NewSource(basePath)) + if err != nil { + return nil, fmt.Errorf("sdk.Diff: failed to load base spec: %w", err) + } + + revSpec, err := load.NewSpecInfo(loader, load.NewSource(revisionPath)) + if err != nil { + return nil, fmt.Errorf("sdk.Diff: failed to load revision spec: %w", err) + } + + // Compute diff + diffResult, err := diff.Get(diff.NewConfig(), baseSpec.Spec, revSpec.Spec) + if err != nil { + return nil, fmt.Errorf("sdk.Diff: failed to compute diff: %w", err) + } + + // Check for breaking changes + config := checker.GetAllChecks() + breaks := checker.CheckBackwardCompatibilityUntilLevel( + config, + diffResult, + baseSpec.Spec, + revSpec.Spec, + checker.ERR, // Only errors (breaking changes) + ) + + // Build result + result := &DiffResult{ + Breaking: len(breaks) > 0, + Changes: make([]string, 0, len(breaks)), + } + + for _, b := range breaks { + result.Changes = append(result.Changes, b.GetUncolorizedText(checker.NewDefaultLocalizer())) + } + + if result.Breaking { + result.Summary = fmt.Sprintf("%d breaking change(s) detected", len(breaks)) + } else { + result.Summary = "No breaking changes" + } + + return result, nil +} + +// DiffExitCode returns the exit code for CI integration. +// 0 = no breaking changes, 1 = breaking changes, 2 = error +func DiffExitCode(result *DiffResult, err error) int { + if err != nil { + return 2 + } + if result.Breaking { + return 1 + } + return 0 +} +``` + +**Step 5: Run tests** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/... -run TestDiff -v` +Expected: PASS + +**Step 6: Commit** + +```bash +git add pkg/sdk/diff.go pkg/sdk/diff_test.go pkg/sdk/go.mod pkg/sdk/go.sum +git commit -m "feat(sdk): add breaking change detection with oasdiff + +Compares OpenAPI specs to detect breaking changes: +- Removed endpoints +- Changed required parameters +- Modified response schemas + +Returns CI-friendly exit codes (0=ok, 1=breaking, 2=error). + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 9: Wire Up Generate Command + +**Files:** +- Modify: `pkg/sdk/sdk.go` + +**Step 1: Update SDK.Generate to use generators** + +```go +// Add to sdk.go, replacing the stub Generate method + +import ( + "github.com/host-uk/core/pkg/sdk/generators" +) + +// Generate generates SDKs for all configured languages. +func (s *SDK) Generate(ctx context.Context) error { + // Detect spec + specPath, err := s.DetectSpec() + if err != nil { + return err + } + + // Create registry with all generators + registry := generators.NewRegistry() + registry.Register(generators.NewTypeScriptGenerator()) + registry.Register(generators.NewPythonGenerator()) + registry.Register(generators.NewGoGenerator()) + registry.Register(generators.NewPHPGenerator()) + + // Generate for each language + for _, lang := range s.config.Languages { + if err := s.GenerateLanguage(ctx, lang); err != nil { + return err + } + } + + return nil +} + +// GenerateLanguage generates SDK for a specific language. +func (s *SDK) GenerateLanguage(ctx context.Context, lang string) error { + specPath, err := s.DetectSpec() + if err != nil { + return err + } + + registry := generators.NewRegistry() + registry.Register(generators.NewTypeScriptGenerator()) + registry.Register(generators.NewPythonGenerator()) + registry.Register(generators.NewGoGenerator()) + registry.Register(generators.NewPHPGenerator()) + + gen, ok := registry.Get(lang) + if !ok { + return fmt.Errorf("sdk.GenerateLanguage: unknown language: %s", lang) + } + + if !gen.Available() { + fmt.Printf("Warning: %s generator not available. Install with: %s\n", lang, gen.Install()) + fmt.Printf("Falling back to Docker...\n") + } + + outputDir := filepath.Join(s.projectDir, s.config.Output, lang) + opts := generators.Options{ + SpecPath: specPath, + OutputDir: outputDir, + PackageName: s.config.Package.Name, + Version: s.config.Package.Version, + } + + fmt.Printf("Generating %s SDK...\n", lang) + if err := gen.Generate(ctx, opts); err != nil { + return fmt.Errorf("sdk.GenerateLanguage: %s generation failed: %w", lang, err) + } + fmt.Printf("Generated %s SDK at %s\n", lang, outputDir) + + return nil +} +``` + +**Step 2: Verify it compiles** + +Run: `cd /Users/snider/Code/Core && go build ./pkg/sdk/...` +Expected: No errors + +**Step 3: Commit** + +```bash +git add pkg/sdk/sdk.go +git commit -m "feat(sdk): wire up Generate to use all generators + +SDK.Generate() and SDK.GenerateLanguage() now use the +generator registry to generate SDKs for configured languages. + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 10: Add CLI Commands + +**Files:** +- Create: `cmd/core/cmd/sdk.go` + +**Step 1: Create SDK command file** + +```go +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/pkg/sdk" + "github.com/leaanthony/clir" +) + +var ( + sdkHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#3b82f6")) + + sdkSuccessStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#22c55e")) + + sdkErrorStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ef4444")) + + sdkDimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) +) + +// AddSDKCommand adds the sdk command and its subcommands. +func AddSDKCommand(app *clir.Cli) { + sdkCmd := app.NewSubCommand("sdk", "Generate and manage API SDKs") + sdkCmd.LongDescription("Generate typed API clients from OpenAPI specs.\n" + + "Supports TypeScript, Python, Go, and PHP.") + + // sdk generate + genCmd := sdkCmd.NewSubCommand("generate", "Generate SDKs from OpenAPI spec") + var specPath, lang string + genCmd.StringFlag("spec", "Path to OpenAPI spec file", &specPath) + genCmd.StringFlag("lang", "Generate only this language", &lang) + genCmd.Action(func() error { + return runSDKGenerate(specPath, lang) + }) + + // sdk diff + diffCmd := sdkCmd.NewSubCommand("diff", "Check for breaking API changes") + var basePath string + diffCmd.StringFlag("base", "Base spec (version tag or file)", &basePath) + diffCmd.StringFlag("spec", "Current spec file", &specPath) + diffCmd.Action(func() error { + return runSDKDiff(basePath, specPath) + }) + + // sdk validate + validateCmd := sdkCmd.NewSubCommand("validate", "Validate OpenAPI spec") + validateCmd.StringFlag("spec", "Path to OpenAPI spec file", &specPath) + validateCmd.Action(func() error { + return runSDKValidate(specPath) + }) +} + +func runSDKGenerate(specPath, lang string) error { + ctx := context.Background() + + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Load config + config := sdk.DefaultConfig() + if specPath != "" { + config.Spec = specPath + } + + s := sdk.New(projectDir, config) + + fmt.Printf("%s Generating SDKs\n", sdkHeaderStyle.Render("SDK:")) + + if lang != "" { + // Generate single language + if err := s.GenerateLanguage(ctx, lang); err != nil { + fmt.Printf("%s %v\n", sdkErrorStyle.Render("Error:"), err) + return err + } + } else { + // Generate all + if err := s.Generate(ctx); err != nil { + fmt.Printf("%s %v\n", sdkErrorStyle.Render("Error:"), err) + return err + } + } + + fmt.Printf("%s SDK generation complete\n", sdkSuccessStyle.Render("Success:")) + return nil +} + +func runSDKDiff(basePath, specPath string) error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Detect current spec if not provided + if specPath == "" { + s := sdk.New(projectDir, nil) + specPath, err = s.DetectSpec() + if err != nil { + return err + } + } + + if basePath == "" { + return fmt.Errorf("--base is required (version tag or file path)") + } + + fmt.Printf("%s Checking for breaking changes\n", sdkHeaderStyle.Render("SDK Diff:")) + fmt.Printf(" Base: %s\n", sdkDimStyle.Render(basePath)) + fmt.Printf(" Current: %s\n", sdkDimStyle.Render(specPath)) + fmt.Println() + + result, err := sdk.Diff(basePath, specPath) + if err != nil { + fmt.Printf("%s %v\n", sdkErrorStyle.Render("Error:"), err) + os.Exit(2) + } + + if result.Breaking { + fmt.Printf("%s %s\n", sdkErrorStyle.Render("Breaking:"), result.Summary) + for _, change := range result.Changes { + fmt.Printf(" - %s\n", change) + } + os.Exit(1) + } + + fmt.Printf("%s %s\n", sdkSuccessStyle.Render("OK:"), result.Summary) + return nil +} + +func runSDKValidate(specPath string) error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + s := sdk.New(projectDir, &sdk.Config{Spec: specPath}) + + fmt.Printf("%s Validating OpenAPI spec\n", sdkHeaderStyle.Render("SDK:")) + + detectedPath, err := s.DetectSpec() + if err != nil { + fmt.Printf("%s %v\n", sdkErrorStyle.Render("Error:"), err) + return err + } + + fmt.Printf(" Spec: %s\n", sdkDimStyle.Render(detectedPath)) + fmt.Printf("%s Spec is valid\n", sdkSuccessStyle.Render("OK:")) + return nil +} +``` + +**Step 2: Register command in root.go** + +Add to root.go after other command registrations: +```go +AddSDKCommand(app) +``` + +**Step 3: Verify it compiles** + +Run: `cd /Users/snider/Code/Core && go build ./cmd/core/...` +Expected: No errors + +**Step 4: Commit** + +```bash +git add cmd/core/cmd/sdk.go cmd/core/cmd/root.go +git commit -m "feat(cli): add sdk command with generate, diff, validate + +Commands: +- core sdk generate [--spec FILE] [--lang LANG] +- core sdk diff --base VERSION [--spec FILE] +- core sdk validate [--spec FILE] + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 11: Add SDK Config to Release Config + +**Files:** +- Modify: `pkg/release/config.go` + +**Step 1: Add SDK field to Config** + +Add to Config struct in config.go: +```go +// SDK configures SDK generation. +SDK *SDKConfig `yaml:"sdk,omitempty"` +``` + +Add SDKConfig type: +```go +// SDKConfig holds SDK generation configuration. +type SDKConfig struct { + // Spec is the path to the OpenAPI spec file. + Spec string `yaml:"spec,omitempty"` + // Languages to generate. + Languages []string `yaml:"languages,omitempty"` + // Output directory (default: sdk/). + Output string `yaml:"output,omitempty"` + // Package naming. + Package SDKPackageConfig `yaml:"package,omitempty"` + // Diff configuration. + Diff SDKDiffConfig `yaml:"diff,omitempty"` + // Publish configuration. + Publish SDKPublishConfig `yaml:"publish,omitempty"` +} + +// SDKPackageConfig holds package naming configuration. +type SDKPackageConfig struct { + Name string `yaml:"name,omitempty"` + Version string `yaml:"version,omitempty"` +} + +// SDKDiffConfig holds diff configuration. +type SDKDiffConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + FailOnBreaking bool `yaml:"fail_on_breaking,omitempty"` +} + +// SDKPublishConfig holds monorepo publish configuration. +type SDKPublishConfig struct { + Repo string `yaml:"repo,omitempty"` + Path string `yaml:"path,omitempty"` +} +``` + +**Step 2: Verify it compiles** + +Run: `cd /Users/snider/Code/Core && go build ./pkg/release/...` +Expected: No errors + +**Step 3: Commit** + +```bash +git add pkg/release/config.go +git commit -m "feat(release): add SDK configuration to release.yaml + +Adds sdk: section to .core/release.yaml for configuring +OpenAPI SDK generation during releases. + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 12: Add SDK Example to Docs + +**Files:** +- Create: `docs/examples/sdk-full.yaml` + +**Step 1: Create example file** + +```yaml +# Example: Full SDK Configuration +# Generate typed API clients from OpenAPI specs + +sdk: + # OpenAPI spec source (auto-detected if omitted) + spec: api/openapi.yaml + + # Languages to generate + languages: + - typescript + - python + - go + - php + + # Output directory (default: sdk/) + output: sdk/ + + # Package naming + package: + name: myapi + version: "{{.Version}}" + + # Breaking change detection + diff: + enabled: true + fail_on_breaking: true # CI fails on breaking changes + + # Optional: publish to monorepo + publish: + repo: myorg/sdks + path: packages/myapi + +# Required tools (install one per language): +# TypeScript: npm i -g openapi-typescript-codegen (or Docker) +# Python: pip install openapi-python-client (or Docker) +# Go: go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest +# PHP: Docker required +# +# Usage: +# core sdk generate # Generate all configured languages +# core sdk generate --lang go # Generate single language +# core sdk diff --base v1.0.0 # Check for breaking changes +# core sdk validate # Validate spec +``` + +**Step 2: Commit** + +```bash +git add docs/examples/sdk-full.yaml +git commit -m "docs: add SDK configuration example + +Shows full SDK config with all options: +- Language selection +- Breaking change detection +- Monorepo publishing + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +### Task 13: Final Integration Test + +**Step 1: Build and verify CLI** + +Run: `cd /Users/snider/Code/Core && go build -o bin/core ./cmd/core && ./bin/core sdk --help` +Expected: Shows sdk command help + +**Step 2: Run all tests** + +Run: `cd /Users/snider/Code/Core && go test ./pkg/sdk/... -v` +Expected: All tests pass + +**Step 3: Final commit if needed** + +```bash +git add -A +git commit -m "chore(sdk): finalize S3.4 SDK generation + +All SDK generation features complete: +- OpenAPI spec detection +- TypeScript, Python, Go, PHP generators +- Breaking change detection with oasdiff +- CLI commands (generate, diff, validate) +- Integration with release config + +Co-Authored-By: Claude Opus 4.5 " +``` + +--- + +## Summary + +13 tasks covering: +1. Package structure +2. Spec detection +3. Generator interface +4. TypeScript generator +5. Python generator +6. Go generator +7. PHP generator +8. Breaking change detection +9. Wire up Generate +10. CLI commands +11. Release config integration +12. Documentation example +13. Integration test