From 3a7349ce5fca71b528cb68cffe6cf28edc90d705 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 01:15:47 +0000 Subject: [PATCH] feat(sdk): add TypeScript generator Implements TypeScript SDK generator with three-level fallback: 1. Native openapi-typescript-codegen if installed globally 2. npx openapi-typescript-codegen if npx available 3. Docker openapitools/openapi-generator-cli as fallback Includes tests following _Good convention for Available and Generate. Co-Authored-By: Claude Opus 4.5 --- pkg/sdk/generators/typescript.go | 106 ++++++++++++++++++++++++++ pkg/sdk/generators/typescript_test.go | 87 +++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 pkg/sdk/generators/typescript.go create mode 100644 pkg/sdk/generators/typescript_test.go diff --git a/pkg/sdk/generators/typescript.go b/pkg/sdk/generators/typescript.go new file mode 100644 index 00000000..72d03b4b --- /dev/null +++ b/pkg/sdk/generators/typescript.go @@ -0,0 +1,106 @@ +package generators + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// TypeScriptGenerator generates TypeScript SDKs from OpenAPI specs. +type TypeScriptGenerator struct{} + +// NewTypeScriptGenerator creates a new TypeScript generator. +func NewTypeScriptGenerator() *TypeScriptGenerator { + return &TypeScriptGenerator{} +} + +// Language returns the generator's target language identifier. +func (g *TypeScriptGenerator) Language() string { + return "typescript" +} + +// Available checks if generator dependencies are installed. +func (g *TypeScriptGenerator) Available() bool { + _, err := exec.LookPath("openapi-typescript-codegen") + if err == nil { + return true + } + _, err = exec.LookPath("npx") + return err == nil +} + +// Install returns instructions for installing the generator. +func (g *TypeScriptGenerator) Install() string { + return "npm install -g openapi-typescript-codegen" +} + +// Generate creates SDK from OpenAPI spec. +func (g *TypeScriptGenerator) Generate(ctx context.Context, opts Options) error { + if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { + return fmt.Errorf("typescript.Generate: failed to create output dir: %w", err) + } + + if g.nativeAvailable() { + return g.generateNative(ctx, opts) + } + if g.npxAvailable() { + return g.generateNpx(ctx, opts) + } + 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 { + 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 +} diff --git a/pkg/sdk/generators/typescript_test.go b/pkg/sdk/generators/typescript_test.go new file mode 100644 index 00000000..3a40443e --- /dev/null +++ b/pkg/sdk/generators/typescript_test.go @@ -0,0 +1,87 @@ +package generators + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// dockerAvailable checks if docker is available for fallback generation. +func dockerAvailable() bool { + _, err := exec.LookPath("docker") + return err == nil +} + +// createTestSpec creates a minimal OpenAPI spec for testing. +func createTestSpec(t *testing.T, dir string) string { + t.Helper() + spec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + summary: Health check + responses: + "200": + description: OK +` + specPath := filepath.Join(dir, "openapi.yaml") + if err := os.WriteFile(specPath, []byte(spec), 0644); err != nil { + t.Fatalf("failed to write test spec: %v", err) + } + return specPath +} + +func TestTypeScriptGenerator_Good_Available(t *testing.T) { + g := NewTypeScriptGenerator() + + // These should not panic + lang := g.Language() + if lang != "typescript" { + t.Errorf("expected language 'typescript', got '%s'", lang) + } + + _ = g.Available() + + install := g.Install() + if install == "" { + t.Error("expected non-empty install instructions") + } +} + +func TestTypeScriptGenerator_Good_Generate(t *testing.T) { + g := NewTypeScriptGenerator() + if !g.Available() && !dockerAvailable() { + t.Skip("no TypeScript generator available (neither native nor docker)") + } + + // Create temp directories + tmpDir := t.TempDir() + specPath := createTestSpec(t, tmpDir) + outputDir := filepath.Join(tmpDir, "output") + + opts := Options{ + SpecPath: specPath, + OutputDir: outputDir, + PackageName: "testclient", + Version: "1.0.0", + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + err := g.Generate(ctx, opts) + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Verify output directory was created + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + t.Error("output directory was not created") + } +}