diff --git a/.gitignore b/.gitignore index b23921b..b1004fd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ coverage.html dist/ # Symlinked from AGENTS.md by mise install -CLAUDE.md \ No newline at end of file +CLAUDE.md + +go.work.sum \ No newline at end of file diff --git a/README.md b/README.md index 6630d07..6a147e5 100644 --- a/README.md +++ b/README.md @@ -95,20 +95,24 @@ go install github.com/speakeasy-api/openapi/cmd/openapi@latest The CLI provides three main command groups: -- **`openapi spec`** - Commands for working with OpenAPI specifications ([documentation](./openapi/cmd/README.md)) +- **`openapi spec`** - Commands for working with OpenAPI specifications ([documentation](./cmd/openapi/commands/openapi/README.md)) - `bootstrap` - Create a new OpenAPI document with best practice examples - `bundle` - Bundle external references into components section - `clean` - Remove unused components from an OpenAPI specification + - `explore` - Interactively explore an OpenAPI specification in the terminal - `inline` - Inline all references in an OpenAPI specification - `join` - Join multiple OpenAPI documents into a single document + - `localize` - Localize an OpenAPI specification by copying external references to a target directory - `optimize` - Optimize an OpenAPI specification by deduplicating inline schemas + - `sanitize` - Remove unwanted elements from an OpenAPI specification + - `snip` - Remove selected operations from an OpenAPI specification (interactive or CLI) - `upgrade` - Upgrade an OpenAPI specification to the latest supported version - `validate` - Validate an OpenAPI specification document -- **`openapi arazzo`** - Commands for working with Arazzo workflow documents ([documentation](./arazzo/cmd/README.md)) +- **`openapi arazzo`** - Commands for working with Arazzo workflow documents ([documentation](./cmd/openapi/commands/arazzo/README.md)) - `validate` - Validate an Arazzo workflow document -- **`openapi overlay`** - Commands for working with OpenAPI overlays ([documentation](./overlay/cmd/README.md)) +- **`openapi overlay`** - Commands for working with OpenAPI overlays ([documentation](./cmd/openapi/commands/overlay/README.md)) - `apply` - Apply an overlay to an OpenAPI specification - `compare` - Compare two specifications and generate an overlay describing differences - `validate` - Validate an OpenAPI overlay document diff --git a/arazzo/cmd/README.md b/cmd/openapi/commands/arazzo/README.md similarity index 100% rename from arazzo/cmd/README.md rename to cmd/openapi/commands/arazzo/README.md diff --git a/arazzo/cmd/root.go b/cmd/openapi/commands/arazzo/root.go similarity index 91% rename from arazzo/cmd/root.go rename to cmd/openapi/commands/arazzo/root.go index 7c05731..cbb2c3d 100644 --- a/arazzo/cmd/root.go +++ b/cmd/openapi/commands/arazzo/root.go @@ -1,4 +1,4 @@ -package cmd +package arazzo import "github.com/spf13/cobra" diff --git a/arazzo/cmd/validate.go b/cmd/openapi/commands/arazzo/validate.go similarity index 99% rename from arazzo/cmd/validate.go rename to cmd/openapi/commands/arazzo/validate.go index a4e169f..1da32e0 100644 --- a/arazzo/cmd/validate.go +++ b/cmd/openapi/commands/arazzo/validate.go @@ -1,4 +1,4 @@ -package cmd +package arazzo import ( "context" diff --git a/openapi/cmd/README.md b/cmd/openapi/commands/openapi/README.md similarity index 77% rename from openapi/cmd/README.md rename to cmd/openapi/commands/openapi/README.md index 1dd5c37..6475ff5 100644 --- a/openapi/cmd/README.md +++ b/cmd/openapi/commands/openapi/README.md @@ -19,6 +19,8 @@ OpenAPI specifications define REST APIs in a standard format. These commands hel - [`optimize`](#optimize) - [`bootstrap`](#bootstrap) - [`localize`](#localize) + - [`explore`](#explore) + - [`snip`](#snip) - [Common Options](#common-options) - [Output Formats](#output-formats) - [Examples](#examples) @@ -730,6 +732,186 @@ Address: - You want to simplify file management for complex multi-file specifications - You're creating documentation packages that include all referenced files +### `explore` + +Interactively explore an OpenAPI specification in a terminal user interface. + +```bash +# Launch the explorer +openapi spec explore ./spec.yaml + +# Get help on keyboard shortcuts +# (Press '?' in the explorer) +``` + +What the explorer provides: + +- **Interactive navigation** - Browse all API operations with vim-style keyboard shortcuts +- **Operation details** - View parameters, request bodies, responses, and more +- **Color-coded methods** - Visual differentiation by HTTP method (GET=green, POST=blue, etc.) +- **Fold/unfold details** - Toggle detailed information with Space or Enter +- **Search through operations** - Quickly find endpoints in large specifications +- **Help modal** - Built-in keyboard shortcut reference + +**Keyboard Navigation:** + +| Key | Action | +| ----------------- | -------------------------- | +| `↑` / `k` | Move up | +| `↓` / `j` | Move down | +| `gg` | Jump to top | +| `G` | Jump to bottom | +| `Ctrl-U` | Scroll up by half screen | +| `Ctrl-D` | Scroll down by half screen | +| `Enter` / `Space` | Toggle operation details | +| `?` | Show/hide help | +| `q` / `Esc` | Quit | + +**What you can view:** + +- Operation ID, summary, and description +- HTTP method and path +- Parameters (name, location, required status, description) +- Request body content types +- Response status codes and descriptions +- Tags and deprecation warnings + +**Benefits:** + +- **Faster understanding** - Quickly grasp API structure without parsing YAML/JSON +- **Better navigation** - Jump between operations more efficiently than text editors +- **Visual clarity** - Color-coding and formatting make operations easier to distinguish +- **No tool installation** - Works in any terminal, no browser required +- **Offline friendly** - Explore specifications without network access + +**Use Explore when:** + +- You need to understand a new or unfamiliar API specification +- You want to quickly review endpoints and their parameters +- You're debugging API structure or looking for specific operations +- You prefer terminal-based workflows over web-based viewers +- You need to present or demo API operations in a meeting + +### `snip` + +Remove selected operations from an OpenAPI specification and automatically clean up unused components. + +```bash +# Interactive mode - browse and select operations via TUI +openapi spec snip ./spec.yaml +openapi spec snip ./spec.yaml ./filtered-spec.yaml + +# CLI mode - remove by operation ID +openapi spec snip --operationId deleteUser --operationId adminDebug ./spec.yaml + +# CLI mode - remove by operation ID (comma-separated) +openapi spec snip --operationId deleteUser,adminDebug ./spec.yaml + +# CLI mode - remove by path:method +openapi spec snip --operation /users/{id}:DELETE --operation /admin:GET ./spec.yaml + +# CLI mode - remove by path:method (comma-separated) +openapi spec snip --operation /users/{id}:DELETE,/admin:GET ./spec.yaml + +# CLI mode - mixed approaches +openapi spec snip --operationId deleteUser --operation /admin:GET ./spec.yaml + +# Write in-place (CLI mode only) +openapi spec snip -w --operation /internal/debug:GET ./spec.yaml +``` + +**Two Operation Modes:** + +**Interactive Mode** (no operation flags): +- Launch a terminal UI to browse all operations +- Select operations with Space key +- Press 'a' to select all, 'A' to deselect all +- Press 'w' to write the result (prompts for file path) +- Press 'q' or Esc to cancel + +**Command-Line Mode** (operation flags specified): +- Remove operations specified via flags without UI +- Supports `--operationId` for operation IDs +- Supports `--operation` for path:method pairs +- Both flags support comma-separated values or multiple flags + +**What snip does:** + +1. Removes the specified operations from the document +2. Removes path items that become empty after operation removal +3. Automatically runs Clean() to remove unused components +4. Preserves all other operations and valid references + +**Before snipping:** + +```yaml +paths: + /users: + get: + operationId: getUsers + responses: + '200': + $ref: '#/components/responses/UserResponse' + delete: + operationId: deleteAllUsers + responses: + '204': + description: No content + /admin/debug: + get: + operationId: debugInfo + responses: + '200': + description: Debug info +components: + schemas: + User: + type: object + UnusedSchema: + type: object + responses: + UserResponse: + description: User response +``` + +**After snipping** (removed deleteAllUsers and debugInfo): + +```yaml +paths: + /users: + get: + operationId: getUsers + responses: + '200': + $ref: '#/components/responses/UserResponse' +components: + schemas: + User: + type: object + responses: + UserResponse: + description: User response +# DELETE operation removed, /admin/debug path removed entirely +# UnusedSchema cleaned up automatically +``` + +**Benefits of snipping:** + +- **Reduce API surface**: Remove deprecated or internal operations before publishing +- **Create filtered specs**: Generate subsets of your API for specific clients or use cases +- **Interactive selection**: Visual browser makes it easy to identify and select operations +- **Automatic cleanup**: Unused components are removed automatically +- **Flexible input**: Support both operation IDs and path:method pairs +- **Batch processing**: Remove multiple operations in one command + +**Use Snip when:** + +- You need to remove deprecated operations from a specification +- You want to create a filtered version of your API for specific clients +- You're preparing a public API specification and need to remove internal endpoints +- You want to reduce the size and complexity of a specification +- You need to create different API variants from a single source + ## Common Options All commands support these common options: diff --git a/openapi/cmd/bootstrap.go b/cmd/openapi/commands/openapi/bootstrap.go similarity index 99% rename from openapi/cmd/bootstrap.go rename to cmd/openapi/commands/openapi/bootstrap.go index 8cb5b6d..6ab6313 100644 --- a/openapi/cmd/bootstrap.go +++ b/cmd/openapi/commands/openapi/bootstrap.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" diff --git a/openapi/cmd/bundle.go b/cmd/openapi/commands/openapi/bundle.go similarity index 99% rename from openapi/cmd/bundle.go rename to cmd/openapi/commands/openapi/bundle.go index 1cd9cd5..f21457a 100644 --- a/openapi/cmd/bundle.go +++ b/cmd/openapi/commands/openapi/bundle.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" diff --git a/openapi/cmd/clean.go b/cmd/openapi/commands/openapi/clean.go similarity index 99% rename from openapi/cmd/clean.go rename to cmd/openapi/commands/openapi/clean.go index 1567057..48609cb 100644 --- a/openapi/cmd/clean.go +++ b/cmd/openapi/commands/openapi/clean.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" @@ -53,9 +53,7 @@ Output options: Run: runClean, } -var ( - cleanWriteInPlace bool -) +var cleanWriteInPlace bool func init() { cleanCmd.Flags().BoolVarP(&cleanWriteInPlace, "write", "w", false, "write result in-place to input file") diff --git a/cmd/openapi/commands/openapi/explore.go b/cmd/openapi/commands/openapi/explore.go new file mode 100644 index 0000000..9694135 --- /dev/null +++ b/cmd/openapi/commands/openapi/explore.go @@ -0,0 +1,121 @@ +package openapi + +import ( + "context" + "fmt" + "os" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" + "github.com/speakeasy-api/openapi/cmd/openapi/internal/explore" + "github.com/speakeasy-api/openapi/cmd/openapi/internal/explore/tui" + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +var exploreCmd = &cobra.Command{ + Use: "explore ", + Short: "Interactively explore an OpenAPI specification", + Long: `Launch an interactive terminal UI to browse and explore OpenAPI operations. + +This command provides a user-friendly interface for navigating through API +endpoints, viewing operation details, parameters, request/response information, +and more. + +Navigation: + ↑/k Move up + ↓/j Move down + gg Jump to top + G Jump to bottom + Ctrl-U Scroll up by half a screen + Ctrl-D Scroll down by half a screen + Enter/Space Toggle operation details + ? Show help + q/Esc Quit + +The explore command helps you understand API structure and operation details +without needing to manually parse the OpenAPI specification file.`, + Args: cobra.ExactArgs(1), + RunE: runExplore, +} + +func runExplore(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + inputFile := args[0] + + // Load the OpenAPI document + doc, err := loadOpenAPIDocument(ctx, inputFile) + if err != nil { + return err + } + + // Collect operations from the document + operations, err := explore.CollectOperations(ctx, doc) + if err != nil { + return fmt.Errorf("failed to collect operations: %w", err) + } + + if len(operations) == 0 { + return fmt.Errorf("no operations found in the OpenAPI document") + } + + // Get document info for display + docTitle := doc.Info.Title + if docTitle == "" { + docTitle = "OpenAPI" + } + docVersion := doc.Info.Version + if docVersion == "" { + docVersion = "unknown" + } + + // Create and run the TUI + m := tui.NewModel(operations, docTitle, docVersion) + p := tea.NewProgram(m, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + return fmt.Errorf("error running explorer: %w", err) + } + + return nil +} + +// loadOpenAPIDocument loads an OpenAPI document from a file +func loadOpenAPIDocument(ctx context.Context, file string) (*openapi.OpenAPI, error) { + cleanFile := filepath.Clean(file) + + f, err := os.Open(cleanFile) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + doc, validationErrors, err := openapi.Unmarshal(ctx, f) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal OpenAPI document: %w", err) + } + if doc == nil { + return nil, fmt.Errorf("failed to parse OpenAPI document: document is nil") + } + + // Report validation errors as warnings but continue + if len(validationErrors) > 0 { + fmt.Fprintf(os.Stderr, "⚠️ Found %d validation errors in document:\n", len(validationErrors)) + for i, validationErr := range validationErrors { + if i < 5 { // Limit to first 5 errors + fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, validationErr.Error()) + } + } + if len(validationErrors) > 5 { + fmt.Fprintf(os.Stderr, " ... and %d more\n", len(validationErrors)-5) + } + fmt.Fprintln(os.Stderr) + } + + return doc, nil +} + +// GetExploreCommand returns the explore command for external use +func GetExploreCommand() *cobra.Command { + return exploreCmd +} diff --git a/openapi/cmd/inline.go b/cmd/openapi/commands/openapi/inline.go similarity index 98% rename from openapi/cmd/inline.go rename to cmd/openapi/commands/openapi/inline.go index a66653f..6bebe4b 100644 --- a/openapi/cmd/inline.go +++ b/cmd/openapi/commands/openapi/inline.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" @@ -41,9 +41,7 @@ Output options: Run: runInline, } -var ( - inlineWriteInPlace bool -) +var inlineWriteInPlace bool func init() { inlineCmd.Flags().BoolVarP(&inlineWriteInPlace, "write", "w", false, "write result in-place to input file") diff --git a/openapi/cmd/join.go b/cmd/openapi/commands/openapi/join.go similarity index 99% rename from openapi/cmd/join.go rename to cmd/openapi/commands/openapi/join.go index db9b5c2..549f5af 100644 --- a/openapi/cmd/join.go +++ b/cmd/openapi/commands/openapi/join.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" diff --git a/openapi/cmd/localize.go b/cmd/openapi/commands/openapi/localize.go similarity index 98% rename from openapi/cmd/localize.go rename to cmd/openapi/commands/openapi/localize.go index 3d087b1..d718b66 100644 --- a/openapi/cmd/localize.go +++ b/cmd/openapi/commands/openapi/localize.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" @@ -81,9 +81,7 @@ After localization (files copied to target directory): Run: runLocalize, } -var ( - localizeNamingStrategy string -) +var localizeNamingStrategy string func init() { localizeCmd.Flags().StringVar(&localizeNamingStrategy, "naming", "path", "Naming strategy for external files: 'path' (path-based) or 'counter' (counter-based)") @@ -144,7 +142,7 @@ func localizeOpenAPI(ctx context.Context, inputFile, targetDirectory string) err } // Create target directory if it doesn't exist - if err := os.MkdirAll(cleanTargetDir, 0750); err != nil { + if err := os.MkdirAll(cleanTargetDir, 0o750); err != nil { return fmt.Errorf("failed to create target directory: %w", err) } diff --git a/openapi/cmd/optimize.go b/cmd/openapi/commands/openapi/optimize.go similarity index 99% rename from openapi/cmd/optimize.go rename to cmd/openapi/commands/openapi/optimize.go index 274870b..8424d87 100644 --- a/openapi/cmd/optimize.go +++ b/cmd/openapi/commands/openapi/optimize.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "bufio" diff --git a/openapi/cmd/root.go b/cmd/openapi/commands/openapi/root.go similarity index 85% rename from openapi/cmd/root.go rename to cmd/openapi/commands/openapi/root.go index ad6f365..e492e60 100644 --- a/openapi/cmd/root.go +++ b/cmd/openapi/commands/openapi/root.go @@ -1,4 +1,4 @@ -package cmd +package openapi import "github.com/spf13/cobra" @@ -14,4 +14,6 @@ func Apply(rootCmd *cobra.Command) { rootCmd.AddCommand(bootstrapCmd) rootCmd.AddCommand(optimizeCmd) rootCmd.AddCommand(localizeCmd) + rootCmd.AddCommand(exploreCmd) + rootCmd.AddCommand(snipCmd) } diff --git a/openapi/cmd/sanitize.go b/cmd/openapi/commands/openapi/sanitize.go similarity index 99% rename from openapi/cmd/sanitize.go rename to cmd/openapi/commands/openapi/sanitize.go index ce0f582..3a1a295 100644 --- a/openapi/cmd/sanitize.go +++ b/cmd/openapi/commands/openapi/sanitize.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" diff --git a/openapi/cmd/shared.go b/cmd/openapi/commands/openapi/shared.go similarity index 99% rename from openapi/cmd/shared.go rename to cmd/openapi/commands/openapi/shared.go index 01b5b2a..74c2d36 100644 --- a/openapi/cmd/shared.go +++ b/cmd/openapi/commands/openapi/shared.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" diff --git a/cmd/openapi/commands/openapi/snip.go b/cmd/openapi/commands/openapi/snip.go new file mode 100644 index 0000000..b6d9bf3 --- /dev/null +++ b/cmd/openapi/commands/openapi/snip.go @@ -0,0 +1,312 @@ +package openapi + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/speakeasy-api/openapi/cmd/openapi/internal/explore" + "github.com/speakeasy-api/openapi/cmd/openapi/internal/explore/tui" + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +var ( + snipWriteInPlace bool + snipOperationIDs []string + snipOperations []string +) + +var snipCmd = &cobra.Command{ + Use: "snip [output-file]", + Short: "Remove operations from an OpenAPI specification", + Long: `Remove selected operations from an OpenAPI specification and clean up unused components. + +This command can operate in two modes: + +1. Interactive Mode (no flags specified): + Launch a terminal UI to browse and select operations for removal. + - Navigate with j/k or arrow keys + - Press Space to select/deselect operations + - Press 'a' to select all, 'A' to deselect all + - Press 'w' to write the result + - Press 'q' or Esc to cancel + +2. Command-Line Mode (--operationId or --operation flags): + Remove operations specified via flags without launching the UI. + +Output options: +- No output file: writes to stdout (pipe-friendly) +- Output file specified: writes to the specified file +- --write flag: writes in-place to the input file + +Examples: + + # Interactive mode - browse and select operations + openapi spec snip ./spec.yaml + openapi spec snip ./spec.yaml ./snipped-spec.yaml + openapi spec snip -w ./spec.yaml + + # CLI mode - remove by operation ID (multiple flags) + openapi spec snip --operationId deleteUser --operationId adminDebug ./spec.yaml + + # CLI mode - remove by operation ID (comma-separated) + openapi spec snip --operationId deleteUser,adminDebug ./spec.yaml + + # CLI mode - remove by path:method (multiple flags) + openapi spec snip --operation /users/{id}:DELETE --operation /admin:GET ./spec.yaml + + # CLI mode - remove by path:method (comma-separated) + openapi spec snip --operation /users/{id}:DELETE,/admin:GET ./spec.yaml + + # CLI mode - mixed operation IDs and path:method + openapi spec snip --operationId deleteUser --operation /admin:GET ./spec.yaml + + # CLI mode - write to stdout for piping + openapi spec snip --operation /internal/debug:GET ./spec.yaml > ./public-spec.yaml`, + Args: cobra.RangeArgs(1, 2), + RunE: runSnip, +} + +func init() { + snipCmd.Flags().BoolVarP(&snipWriteInPlace, "write", "w", false, "write result in-place to input file") + snipCmd.Flags().StringSliceVar(&snipOperationIDs, "operationId", nil, "operation ID to remove (can be comma-separated or repeated)") + snipCmd.Flags().StringSliceVar(&snipOperations, "operation", nil, "operation as path:method to remove (can be comma-separated or repeated)") +} + +func runSnip(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + inputFile := args[0] + + var outputFile string + if len(args) > 1 { + outputFile = args[1] + } + + // Check if any operations were specified via flags + hasOperationFlags := len(snipOperationIDs) > 0 || len(snipOperations) > 0 + + // If -w is specified without operation flags, error + if snipWriteInPlace && !hasOperationFlags { + return fmt.Errorf("--write flag requires specifying operations via --operationId or --operation flags") + } + + if !hasOperationFlags { + // No flags - interactive mode + return runSnipInteractive(ctx, inputFile, outputFile) + } + + // Flags specified - CLI mode + return runSnipCLI(ctx, inputFile, outputFile) +} + +func runSnipCLI(ctx context.Context, inputFile, outputFile string) error { + // Create processor + processor, err := NewOpenAPIProcessor(inputFile, outputFile, snipWriteInPlace) + if err != nil { + return err + } + + // Load document + doc, validationErrors, err := processor.LoadDocument(ctx) + if err != nil { + return err + } + + // Report validation errors (if any) + processor.ReportValidationErrors(validationErrors) + + // Parse operation flags + operationsToRemove, err := parseOperationFlags() + if err != nil { + return err + } + + if len(operationsToRemove) == 0 { + return fmt.Errorf("no operations specified for removal") + } + + // Perform the snip + removed, err := openapi.Snip(ctx, doc, operationsToRemove) + if err != nil { + return fmt.Errorf("failed to snip operations: %w", err) + } + + processor.PrintSuccess(fmt.Sprintf("Successfully removed %d operation(s) and cleaned unused components", removed)) + + // Write the snipped document + return processor.WriteDocument(ctx, doc) +} + +func runSnipInteractive(ctx context.Context, inputFile, outputFile string) error { + // Load the OpenAPI document + doc, err := loadOpenAPIDocument(ctx, inputFile) + if err != nil { + return err + } + + // Collect operations + operations, err := explore.CollectOperations(ctx, doc) + if err != nil { + return fmt.Errorf("failed to collect operations: %w", err) + } + + if len(operations) == 0 { + return fmt.Errorf("no operations found in the OpenAPI document") + } + + // Get document info + docTitle := doc.Info.Title + if docTitle == "" { + docTitle = "OpenAPI" + } + docVersion := doc.Info.Version + if docVersion == "" { + docVersion = "unknown" + } + + // Create TUI config for snip mode + exploreConfig := tui.ExploreConfig{ + Title: "OpenAPI Spec Snip - Select Operations to Remove", + ModeLabel: "Snip Mode", + FooterHelpText: "Space: select | a: all | A: none | w: write | ?: help | q: cancel", + HelpTitle: "Snip Mode Help", + } + + selectionConfig := tui.SelectionConfig{ + Enabled: true, + SelectIcon: "✂️", + SelectColor: "#10B981", // colorGreen + StatusFormat: "Selected: %d operations", + ActionKeys: []tui.ActionKey{ + {Key: "w", Label: "Write and save"}, + }, + } + + config := tui.Config{ + Explore: exploreConfig, + Selection: selectionConfig, + } + + // Create and run the TUI + m := tui.NewModelWithConfig(operations, docTitle, docVersion, config) + p := tea.NewProgram(m, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("error running snip UI: %w", err) + } + + // Get the final model state + tuiModel, ok := finalModel.(tui.Model) + if !ok { + return fmt.Errorf("unexpected model type") + } + + // Check if user performed an action or just quit + actionKey := tuiModel.GetActionKey() + if actionKey == "" { + // User cancelled (pressed q or Esc) + fmt.Println("Snip cancelled - no changes made") + return nil + } + + // Get selected operations + selectedOps := tuiModel.GetSelectedOperations() + if len(selectedOps) == 0 { + fmt.Println("No operations selected - no changes made") + return nil + } + + // Convert to operation identifiers + var operationsToRemove []openapi.OperationIdentifier + for _, op := range selectedOps { + operationsToRemove = append(operationsToRemove, openapi.OperationIdentifier{ + Path: op.Path, + Method: op.Method, + }) + } + + // Perform the snip + removed, err := openapi.Snip(ctx, doc, operationsToRemove) + if err != nil { + return fmt.Errorf("failed to snip operations: %w", err) + } + + fmt.Printf("✅ Successfully removed %d operation(s) and cleaned unused components\n", removed) + + // Determine default output path (prefer outputFile if specified, otherwise inputFile) + defaultPath := outputFile + if defaultPath == "" { + defaultPath = inputFile + } + + // Prompt user for output location using TUI + finalOutputFile, err := tui.PromptForFilePath("Save snipped spec to:", defaultPath) + if err != nil { + return fmt.Errorf("error prompting for file path: %w", err) + } + + if finalOutputFile == "" { + // User cancelled + fmt.Println("Cancelled - no changes saved") + return nil + } + + // Write the result + writeInPlace := (finalOutputFile == inputFile) + processor, err := NewOpenAPIProcessor(inputFile, finalOutputFile, writeInPlace) + if err != nil { + return err + } + + return processor.WriteDocument(ctx, doc) +} + +// parseOperationFlags parses the operation flags into operation identifiers +// Handles both repeated flags and comma-separated values +func parseOperationFlags() ([]openapi.OperationIdentifier, error) { + var operations []openapi.OperationIdentifier + + // Parse operation IDs (handles comma-separated values automatically via StringSlice) + for _, opID := range snipOperationIDs { + if opID != "" { + operations = append(operations, openapi.OperationIdentifier{ + OperationID: opID, + }) + } + } + + // Parse path:method operations (handles comma-separated values automatically) + for _, op := range snipOperations { + if op == "" { + continue + } + + // Must be in path:method format + parts := strings.SplitN(op, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid operation format: %s (expected path:METHOD format, e.g., /users:GET)", op) + } + + path := parts[0] + method := strings.ToUpper(parts[1]) + + if path == "" || method == "" { + return nil, fmt.Errorf("invalid operation format: %s (path and method cannot be empty)", op) + } + + operations = append(operations, openapi.OperationIdentifier{ + Path: path, + Method: method, + }) + } + + return operations, nil +} + +// GetSnipCommand returns the snip command for external use +func GetSnipCommand() *cobra.Command { + return snipCmd +} diff --git a/openapi/cmd/upgrade.go b/cmd/openapi/commands/openapi/upgrade.go similarity index 99% rename from openapi/cmd/upgrade.go rename to cmd/openapi/commands/openapi/upgrade.go index fb30251..f6fa0cf 100644 --- a/openapi/cmd/upgrade.go +++ b/cmd/openapi/commands/openapi/upgrade.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" diff --git a/openapi/cmd/validate.go b/cmd/openapi/commands/openapi/validate.go similarity index 99% rename from openapi/cmd/validate.go rename to cmd/openapi/commands/openapi/validate.go index 1e9c834..a338d90 100644 --- a/openapi/cmd/validate.go +++ b/cmd/openapi/commands/openapi/validate.go @@ -1,4 +1,4 @@ -package cmd +package openapi import ( "context" diff --git a/overlay/cmd/README.md b/cmd/openapi/commands/overlay/README.md similarity index 100% rename from overlay/cmd/README.md rename to cmd/openapi/commands/overlay/README.md diff --git a/overlay/cmd/apply.go b/cmd/openapi/commands/overlay/apply.go similarity index 70% rename from overlay/cmd/apply.go rename to cmd/openapi/commands/overlay/apply.go index 1ed16fa..7995412 100644 --- a/overlay/cmd/apply.go +++ b/cmd/openapi/commands/overlay/apply.go @@ -1,4 +1,4 @@ -package cmd +package overlay import ( "os" @@ -8,14 +8,12 @@ import ( "gopkg.in/yaml.v3" ) -var ( - applyCmd = &cobra.Command{ - Use: "apply [ ]", - Short: "Given an overlay, it will apply it to the spec. If omitted, spec will be loaded via extends (only from local file system).", - Args: cobra.RangeArgs(1, 2), - Run: RunApply, - } -) +var applyCmd = &cobra.Command{ + Use: "apply [ ]", + Short: "Given an overlay, it will apply it to the spec. If omitted, spec will be loaded via extends (only from local file system).", + Args: cobra.RangeArgs(1, 2), + Run: RunApply, +} func RunApply(cmd *cobra.Command, args []string) { overlayFile := args[0] diff --git a/overlay/cmd/compare.go b/cmd/openapi/commands/overlay/compare.go similarity index 75% rename from overlay/cmd/compare.go rename to cmd/openapi/commands/overlay/compare.go index 679234d..b1ced44 100644 --- a/overlay/cmd/compare.go +++ b/cmd/openapi/commands/overlay/compare.go @@ -1,4 +1,4 @@ -package cmd +package overlay import ( "fmt" @@ -9,14 +9,12 @@ import ( "github.com/spf13/cobra" ) -var ( - compareCmd = &cobra.Command{ - Use: "compare ", - Short: "Given two specs, it will output an overlay that describes the differences between them", - Args: cobra.ExactArgs(2), - Run: RunCompare, - } -) +var compareCmd = &cobra.Command{ + Use: "compare ", + Short: "Given two specs, it will output an overlay that describes the differences between them", + Args: cobra.ExactArgs(2), + Run: RunCompare, +} func RunCompare(cmd *cobra.Command, args []string) { y1, err := loader.LoadSpecification(args[0]) diff --git a/overlay/cmd/root.go b/cmd/openapi/commands/overlay/root.go similarity index 91% rename from overlay/cmd/root.go rename to cmd/openapi/commands/overlay/root.go index 33bdf31..1aecb92 100644 --- a/overlay/cmd/root.go +++ b/cmd/openapi/commands/overlay/root.go @@ -1,4 +1,4 @@ -package cmd +package overlay import "github.com/spf13/cobra" diff --git a/overlay/cmd/shared.go b/cmd/openapi/commands/overlay/shared.go similarity index 92% rename from overlay/cmd/shared.go rename to cmd/openapi/commands/overlay/shared.go index ac7d72b..9ca683e 100644 --- a/overlay/cmd/shared.go +++ b/cmd/openapi/commands/overlay/shared.go @@ -1,4 +1,4 @@ -package cmd +package overlay import ( "fmt" diff --git a/overlay/cmd/validate.go b/cmd/openapi/commands/overlay/validate.go similarity index 60% rename from overlay/cmd/validate.go rename to cmd/openapi/commands/overlay/validate.go index 6ec1fda..f96160c 100644 --- a/overlay/cmd/validate.go +++ b/cmd/openapi/commands/overlay/validate.go @@ -1,4 +1,4 @@ -package cmd +package overlay import ( "fmt" @@ -7,14 +7,12 @@ import ( "github.com/spf13/cobra" ) -var ( - validateCmd = &cobra.Command{ - Use: "validate ", - Short: "Given an overlay, it will state whether it appears to be valid or describe the problems found", - Args: cobra.ExactArgs(1), - Run: RunValidateOverlay, - } -) +var validateCmd = &cobra.Command{ + Use: "validate ", + Short: "Given an overlay, it will state whether it appears to be valid or describe the problems found", + Args: cobra.ExactArgs(1), + Run: RunValidateOverlay, +} func RunValidateOverlay(cmd *cobra.Command, args []string) { o, err := loader.LoadOverlay(args[0]) diff --git a/cmd/openapi/go.mod b/cmd/openapi/go.mod new file mode 100644 index 0000000..3bce3b8 --- /dev/null +++ b/cmd/openapi/go.mod @@ -0,0 +1,46 @@ +module github.com/speakeasy-api/openapi/cmd/openapi + +go 1.24.3 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/speakeasy-api/openapi v0.0.0 + github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/speakeasy-api/jsonpath v0.6.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect +) + +replace github.com/speakeasy-api/openapi => ../.. diff --git a/cmd/openapi/go.sum b/cmd/openapi/go.sum new file mode 100644 index 0000000..297b003 --- /dev/null +++ b/cmd/openapi/go.sum @@ -0,0 +1,120 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 h1:aRd8M7HJVZOqn/vhOzrGcQH0lNAMkqMn+pXUYkatmcA= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2 h1:uqH7bpe+ERSiDa34FDOF7RikN6RzXgduUF8yarlZp94= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ= +github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/openapi/internal/explore/collector.go b/cmd/openapi/internal/explore/collector.go new file mode 100644 index 0000000..29154a0 --- /dev/null +++ b/cmd/openapi/internal/explore/collector.go @@ -0,0 +1,52 @@ +package explore + +import ( + "context" + "sort" + "strings" + + "github.com/speakeasy-api/openapi/openapi" +) + +// CollectOperations walks the OpenAPI document and collects all operations +func CollectOperations(ctx context.Context, doc *openapi.OpenAPI) ([]OperationInfo, error) { + var operations []OperationInfo + + for item := range openapi.Walk(ctx, doc) { + err := item.Match(openapi.Matcher{ + Operation: func(op *openapi.Operation) error { + method, path := openapi.ExtractMethodAndPath(item.Location) + if method == "" || path == "" { + return nil + } + + operations = append(operations, OperationInfo{ + Path: path, + Method: strings.ToUpper(method), // Uppercase for display + OperationID: op.GetOperationID(), + Summary: op.GetSummary(), + Description: op.GetDescription(), + Tags: op.GetTags(), + Deprecated: op.GetDeprecated(), + Operation: op, + Folded: true, // Start with details folded + }) + return nil + }, + }) + if err != nil { + return nil, err + } + } + + // Sort operations for stable, predictable display + // First by path, then by method + sort.Slice(operations, func(i, j int) bool { + if operations[i].Path != operations[j].Path { + return operations[i].Path < operations[j].Path + } + return operations[i].Method < operations[j].Method + }) + + return operations, nil +} diff --git a/cmd/openapi/internal/explore/collector_test.go b/cmd/openapi/internal/explore/collector_test.go new file mode 100644 index 0000000..3c8b154 --- /dev/null +++ b/cmd/openapi/internal/explore/collector_test.go @@ -0,0 +1,162 @@ +package explore + +import ( + "testing" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectOperations_Success(t *testing.T) { + t.Parallel() + ctx := t.Context() + + // Create a simple test OpenAPI document + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: openapi.NewPaths(), + } + + // Add a simple path with operations + pathItem := openapi.NewPathItem() + + // Add GET operation + getOp := &openapi.Operation{ + OperationID: strPtr("getUsers"), + Summary: strPtr("Get all users"), + Description: strPtr("Returns a list of all users"), + Tags: []string{"users"}, + } + pathItem.Set(openapi.HTTPMethodGet, getOp) + + // Add POST operation + postOp := &openapi.Operation{ + OperationID: strPtr("createUser"), + Summary: strPtr("Create a user"), + Description: strPtr("Creates a new user"), + Tags: []string{"users"}, + } + pathItem.Set(openapi.HTTPMethodPost, postOp) + + refPathItem := &openapi.ReferencedPathItem{ + Object: pathItem, + } + doc.Paths.Set("/users", refPathItem) + + // Collect operations + operations, err := CollectOperations(ctx, doc) + require.NoError(t, err, "should collect operations without error") + + // Verify results + assert.Len(t, operations, 2, "should collect 2 operations") + + // Check first operation (GET, should come before POST due to sorting) + assert.Equal(t, "GET", operations[0].Method) + assert.Equal(t, "/users", operations[0].Path) + assert.Equal(t, "getUsers", operations[0].OperationID) + assert.Equal(t, "Get all users", operations[0].Summary) + assert.Equal(t, "Returns a list of all users", operations[0].Description) + assert.Equal(t, []string{"users"}, operations[0].Tags) + assert.True(t, operations[0].Folded, "should start folded") + + // Check second operation + assert.Equal(t, "POST", operations[1].Method) + assert.Equal(t, "/users", operations[1].Path) + assert.Equal(t, "createUser", operations[1].OperationID) + assert.Equal(t, "Create a user", operations[1].Summary) +} + +func TestCollectOperations_MultiplePathsSorted(t *testing.T) { + t.Parallel() + ctx := t.Context() + + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: openapi.NewPaths(), + } + + // Add paths in non-alphabetical order + paths := []string{"/users", "/pets", "/admin"} + for _, path := range paths { + pathItem := openapi.NewPathItem() + op := &openapi.Operation{ + Summary: strPtr("Operation for " + path), + } + pathItem.Set(openapi.HTTPMethodGet, op) + refPathItem := &openapi.ReferencedPathItem{Object: pathItem} + doc.Paths.Set(path, refPathItem) + } + + operations, err := CollectOperations(ctx, doc) + require.NoError(t, err) + + // Verify operations are sorted by path + assert.Equal(t, "/admin", operations[0].Path) + assert.Equal(t, "/pets", operations[1].Path) + assert.Equal(t, "/users", operations[2].Path) +} + +func TestCollectOperations_EmptyDocument(t *testing.T) { + t.Parallel() + ctx := t.Context() + + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Empty API", + Version: "1.0.0", + }, + Paths: openapi.NewPaths(), + } + + operations, err := CollectOperations(ctx, doc) + require.NoError(t, err) + assert.Empty(t, operations, "should return empty slice for document with no operations") +} + +func TestCollectOperations_DeprecatedOperation(t *testing.T) { + t.Parallel() + ctx := t.Context() + + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: openapi.NewPaths(), + } + + pathItem := openapi.NewPathItem() + op := &openapi.Operation{ + OperationID: strPtr("deprecatedOp"), + Deprecated: boolPtr(true), + } + pathItem.Set(openapi.HTTPMethodGet, op) + refPathItem := &openapi.ReferencedPathItem{Object: pathItem} + doc.Paths.Set("/deprecated", refPathItem) + + operations, err := CollectOperations(ctx, doc) + require.NoError(t, err) + require.Len(t, operations, 1) + + assert.True(t, operations[0].Deprecated, "should capture deprecated status") +} + +// Helper functions +func strPtr(s string) *string { + return &s +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/cmd/openapi/internal/explore/model.go b/cmd/openapi/internal/explore/model.go new file mode 100644 index 0000000..0ee725b --- /dev/null +++ b/cmd/openapi/internal/explore/model.go @@ -0,0 +1,48 @@ +package explore + +import "github.com/speakeasy-api/openapi/openapi" + +// OperationInfo represents a single API operation with its metadata +type OperationInfo struct { + // Path is the endpoint path (e.g., "/users/{id}") + Path string + // Method is the HTTP method (e.g., "GET", "POST") + Method string + // OperationID is the unique identifier for the operation + OperationID string + // Summary is a short summary of what the operation does + Summary string + // Description is a verbose explanation of the operation behavior + Description string + // Tags is a list of tags for API documentation control + Tags []string + // Deprecated indicates if the operation is deprecated + Deprecated bool + + // Operation is the full operation object for detailed inspection + Operation *openapi.Operation + + // Folded tracks whether details are hidden in the UI + Folded bool +} + +// GetDisplaySummary returns a display-friendly summary +// Returns the summary if available, otherwise a truncated description +func (o *OperationInfo) GetDisplaySummary() string { + if o.Summary != "" { + return o.Summary + } + if o.Description != "" && len(o.Description) > 60 { + return o.Description[:57] + "..." + } + return o.Description +} + +// HasDetails returns true if the operation has additional details to display +func (o *OperationInfo) HasDetails() bool { + return o.Summary != "" || + o.Description != "" || + len(o.Operation.GetParameters()) > 0 || + o.Operation.GetRequestBody() != nil || + o.Operation.GetResponses() != nil +} diff --git a/cmd/openapi/internal/explore/tui/input.go b/cmd/openapi/internal/explore/tui/input.go new file mode 100644 index 0000000..990af4d --- /dev/null +++ b/cmd/openapi/internal/explore/tui/input.go @@ -0,0 +1,123 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// InputModel is a simple text input TUI for getting file paths +type InputModel struct { + textInput textinput.Model + prompt string + err error + submitted bool + cancelled bool +} + +// NewInputModel creates a new input model with the given prompt and default value +func NewInputModel(prompt, defaultValue string) InputModel { + ti := textinput.New() + ti.Placeholder = defaultValue + ti.SetValue(defaultValue) + ti.Focus() + ti.CharLimit = 256 + ti.Width = 60 + + return InputModel{ + textInput: ti, + prompt: prompt, + err: nil, + submitted: false, + cancelled: false, + } +} + +// Init initializes the model +func (m InputModel) Init() tea.Cmd { + return textinput.Blink +} + +// Update handles messages +func (m InputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + m.submitted = true + return m, tea.Quit + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + return m, tea.Quit + } + } + + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +// View renders the input +func (m InputModel) View() string { + style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorThemePurple)). + Padding(1, 2). + Width(70) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorThemePurple)) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorGray)). + Italic(true) + + content := fmt.Sprintf("%s\n\n%s\n\n%s", + titleStyle.Render(m.prompt), + m.textInput.View(), + helpStyle.Render("Enter: confirm • Esc: cancel")) + + return lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, style.Render(content)) +} + +// GetValue returns the submitted value +func (m InputModel) GetValue() string { + if m.submitted { + return m.textInput.Value() + } + return "" +} + +// IsCancelled returns true if the user cancelled +func (m InputModel) IsCancelled() bool { + return m.cancelled +} + +// PromptForFilePath shows a TUI prompt for a file path with a default value +// Returns the path or empty string if cancelled +func PromptForFilePath(prompt, defaultValue string) (string, error) { + m := NewInputModel(prompt, defaultValue) + p := tea.NewProgram(m) + + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("error running input prompt: %w", err) + } + + inputModel, ok := finalModel.(InputModel) + if !ok { + return "", fmt.Errorf("unexpected model type") + } + + if inputModel.IsCancelled() { + return "", nil + } + + value := strings.TrimSpace(inputModel.GetValue()) + return value, nil +} diff --git a/cmd/openapi/internal/explore/tui/model.go b/cmd/openapi/internal/explore/tui/model.go new file mode 100644 index 0000000..9819f08 --- /dev/null +++ b/cmd/openapi/internal/explore/tui/model.go @@ -0,0 +1,411 @@ +package tui + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/speakeasy-api/openapi/cmd/openapi/internal/explore" +) + +const ( + // keySequenceThreshold is the max time between key presses for sequences like "gg" + keySequenceThreshold = 500 * time.Millisecond + + // scrollHalfScreenLines is the number of lines to scroll with Ctrl-D/U + scrollHalfScreenLines = 21 + + // Layout constants + headerApproxLines = 2 // Single line header + one empty line + footerApproxLines = 4 + layoutBuffer = 2 // Extra buffer to ensure header visibility + leftPaddingChars = 2 // "▶" + space +) + +// Config contains all configuration for the TUI +type Config struct { + // Explore configures the explore mode appearance and text + Explore ExploreConfig + // Selection configures optional selection mode behavior + Selection SelectionConfig +} + +// ExploreConfig configures the explore mode (browsing) appearance and text +type ExploreConfig struct { + // Title is the main title shown in the header + Title string + // ModeLabel is the label shown in the navigation section + ModeLabel string + // FooterHelpText is the help text shown in the footer + FooterHelpText string + // HelpTitle is the title for the help modal + HelpTitle string +} + +// ActionKey represents a key that triggers an action in selection mode +type ActionKey struct { + // Key is the keyboard key (e.g., "w", "d", "ctrl+s") + Key string + // Label is the description for help text (e.g., "Write and save", "Discard changes") + Label string +} + +// SelectionConfig configures optional selection mode behavior +// When Enabled is true, users can select operations with Space +type SelectionConfig struct { + // Enabled indicates if selection mode is active + Enabled bool + // SelectIcon is the icon shown for selected items (e.g., "✂️", "✓", "❌") + SelectIcon string + // SelectColor is the lipgloss color for selected items (e.g., colorGreen) + SelectColor string + // StatusFormat is a format string for the footer status (receives selected count as %d) + // Example: "Selected: %d operations" + StatusFormat string + // ActionKeys defines the keys that trigger actions when pressed + // Example: []ActionKey{{Key: "w", Label: "Write and save"}} + ActionKeys []ActionKey +} + +// DefaultConfig returns the default configuration for view-only mode +func DefaultConfig() Config { + return Config{ + Explore: ExploreConfig{ + Title: "OpenAPI Spec Explorer", + ModeLabel: "Operations", + FooterHelpText: "Press '?' for help", + HelpTitle: "Help", + }, + Selection: SelectionConfig{ + Enabled: false, + }, + } +} + +// Model represents the TUI application state +type Model struct { + // Data + operations []explore.OperationInfo + docTitle string + docVersion string + + // Configuration + config Config + + // UI state + cursor int + width int + height int + scrollOffset int + showHelp bool + + // Selection state (only used when selectionConfig.Enabled is true) + selected map[int]bool + + // Key sequence handling + lastKey string + lastKeyAt time.Time + + // Terminal state + quitting bool + actionKey string // The action key that was pressed (only set when quitting in selection mode) +} + +// NewModel creates a new TUI model with default view-only configuration +func NewModel(operations []explore.OperationInfo, docTitle, docVersion string) Model { + return NewModelWithConfig(operations, docTitle, docVersion, DefaultConfig()) +} + +// NewModelWithConfig creates a new TUI model with custom configuration +func NewModelWithConfig(operations []explore.OperationInfo, docTitle, docVersion string, config Config) Model { + return Model{ + operations: operations, + docTitle: docTitle, + docVersion: docVersion, + config: config, + cursor: 0, + width: 80, + height: 24, + scrollOffset: 0, + showHelp: false, + selected: make(map[int]bool), + lastKey: "", + lastKeyAt: time.Time{}, + quitting: false, + actionKey: "", + } +} + +// GetSelectedOperations returns the operations that have been selected +// Only relevant when selectionConfig.Enabled is true +func (m Model) GetSelectedOperations() []explore.OperationInfo { + var selected []explore.OperationInfo + for idx := range m.selected { + if idx < len(m.operations) { + selected = append(selected, m.operations[idx]) + } + } + return selected +} + +// GetActionKey returns the action key that was pressed to quit (only relevant in selection mode) +// Returns empty string if user cancelled or quit without an action +func (m Model) GetActionKey() string { + return m.actionKey +} + +// Init initializes the model (required by bubbletea) +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles messages and updates the model (required by bubbletea) +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + if m.showHelp { + m.showHelp = false + } else { + m.quitting = true + return m, tea.Quit + } + + case "?": + m.showHelp = !m.showHelp + + case "esc": + if m.showHelp { + m.showHelp = false + } + + case "up", "k": + if !m.showHelp && m.cursor > 0 { + m.cursor-- + m.ensureCursorVisible() + } + + case "down", "j": + if !m.showHelp && m.cursor < len(m.operations)-1 { + m.cursor++ + m.ensureCursorVisible() + } + + case "ctrl+d": + if !m.showHelp { + maxItems := len(m.operations) - 1 + newCursorPos := m.cursor + scrollHalfScreenLines + + if newCursorPos > maxItems { + m.cursor = maxItems + } else { + m.cursor = newCursorPos + } + + m.ensureCursorVisible() + } + + case "ctrl+u": + if !m.showHelp { + halfLines := max(1, m.calculateContentHeight()/2) + if m.cursor < halfLines { + m.cursor = 0 + } else { + m.cursor -= halfLines + } + + m.ensureCursorVisible() + } + + case "G": + if !m.showHelp && len(m.operations) > 0 { + m.cursor = len(m.operations) - 1 + m.ensureCursorVisible() + } + + case "g": + now := time.Now() + if m.lastKey == "g" && now.Sub(m.lastKeyAt) < keySequenceThreshold { + if !m.showHelp { + m.cursor = 0 + m.ensureCursorVisible() + } + + // Reset so "ggg" wouldn't be triggered + m.lastKey = "" + m.lastKeyAt = time.Time{} + } else { + m.lastKey = "g" + m.lastKeyAt = now + } + + case " ": + if !m.showHelp && m.cursor < len(m.operations) { + if m.config.Selection.Enabled { + // In selection mode, space toggles selection + m.selected[m.cursor] = !m.selected[m.cursor] + } else { + // In view mode, space toggles fold + m.operations[m.cursor].Folded = !m.operations[m.cursor].Folded + } + } + + case "enter": + // Enter always toggles details (in both modes) + if !m.showHelp && m.cursor < len(m.operations) { + m.operations[m.cursor].Folded = !m.operations[m.cursor].Folded + } + + case "a": + // Select all (only in selection mode) + if !m.showHelp && m.config.Selection.Enabled { + for i := range m.operations { + m.selected[i] = true + } + } + + case "A": + // Deselect all (only in selection mode) + if !m.showHelp && m.config.Selection.Enabled { + m.selected = make(map[int]bool) + } + + default: + // Check for action keys in selection mode + if !m.showHelp && m.config.Selection.Enabled { + for _, action := range m.config.Selection.ActionKeys { + if msg.String() == action.Key { + // Action key pressed - quit and return which action + m.actionKey = action.Key + m.quitting = true + return m, tea.Quit + } + } + } + } + } + + return m, nil +} + +// View renders the current state (required by bubbletea) +func (m Model) View() string { + if m.showHelp { + return m.renderHelpModal() + } + + var s strings.Builder + + header := m.renderHeader() + footer := m.renderFooter() + content := m.renderOperations() + + headerLines := strings.Count(header, "\n") + footerLines := strings.Count(footer, "\n") + contentLines := strings.Count(content, "\n") + + // Build the view + s.WriteString(header) + s.WriteString(content) + + // Add padding to fill remaining space + usedLines := headerLines + contentLines + footerLines + remainingLines := m.height - usedLines - 1 + if remainingLines > 0 { + s.WriteString(strings.Repeat("\n", remainingLines)) + } + + s.WriteString(footer) + + return s.String() +} + +// calculateContentHeight returns the available height for content +func (m Model) calculateContentHeight() int { + return max(1, m.height-headerApproxLines-footerApproxLines-layoutBuffer) +} + +// calculateContentWidth returns the available width for content +func (m Model) calculateContentWidth() int { + return max(1, m.width-leftPaddingChars) +} + +// getItemHeight returns the height in lines of an item at the given index +func (m Model) getItemHeight(index int) int { + if index >= len(m.operations) { + return 1 + } + + op := m.operations[index] + + if op.Folded { + return 1 // Just the main line when folded + } + + // When unfolded, count main line + detail lines (regardless of selection) + details := m.formatOperationDetails(op) + return 1 + strings.Count(details, "\n") + 1 +} + +// ensureCursorVisible adjusts scrollOffset to keep cursor visible +func (m *Model) ensureCursorVisible() { + contentHeight := m.calculateContentHeight() + + // Special case: if cursor is at 0, scroll to the very top + if m.cursor == 0 { + m.scrollOffset = 0 + return + } + + // If cursor is above current scroll position, scroll up to show it + if m.cursor < m.scrollOffset { + m.scrollOffset = m.cursor + return + } + + // Calculate how many lines are used from scrollOffset to cursor (inclusive) + linesUsed := 0 + + // Account for scroll indicator + if m.scrollOffset > 0 { + linesUsed++ // "More items above" indicator + } + + // Add lines for each item + for i := m.scrollOffset; i <= m.cursor && i < len(m.operations); i++ { + linesUsed += m.getItemHeight(i) + } + + // If the cursor item extends beyond available content height, scroll down + if linesUsed > contentHeight { + // Find the minimum scroll offset that keeps cursor visible + for newScrollOffset := m.scrollOffset + 1; newScrollOffset <= m.cursor; newScrollOffset++ { + testLinesUsed := 0 + + // Account for "More items above" indicator + if newScrollOffset > 0 { + testLinesUsed++ + } + + // Calculate lines from new scroll offset to cursor + for i := newScrollOffset; i <= m.cursor && i < len(m.operations); i++ { + testLinesUsed += m.getItemHeight(i) + } + + if testLinesUsed <= contentHeight { + m.scrollOffset = newScrollOffset + break + } + } + } + + // Ensure scroll offset doesn't go negative + if m.scrollOffset < 0 { + m.scrollOffset = 0 + } +} diff --git a/cmd/openapi/internal/explore/tui/styles.go b/cmd/openapi/internal/explore/tui/styles.go new file mode 100644 index 0000000..8f7016d --- /dev/null +++ b/cmd/openapi/internal/explore/tui/styles.go @@ -0,0 +1,115 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +// Color palette +const ( + colorGreen = "#10B981" + colorBlue = "#3B82F6" + colorYellow = "#F59E0B" + colorRed = "#EF4444" + colorPurple = "#8B5CF6" + colorGray = "#6B7280" + colorThemePurple = "#7C3AED" + colorBackground = "#374151" + colorDetailGray = "#9CA3AF" + colorFooterText = "#000000" + colorWhite = "#FFFFFF" +) + +// methodColors maps HTTP methods to their display colors +var methodColors = map[string]lipgloss.Color{ + "GET": colorGreen, + "POST": colorBlue, + "PUT": colorYellow, + "DELETE": colorRed, + "PATCH": colorPurple, + "HEAD": colorGray, + "OPTIONS": colorGray, + "TRACE": colorGray, +} + +// GetMethodColor returns the color for a given HTTP method +func GetMethodColor(method string) lipgloss.Color { + if color, ok := methodColors[method]; ok { + return color + } + return colorGray +} + +// Common styles +var ( + // ButtonStyle is the default style for navigation buttons + ButtonStyle = lipgloss.NewStyle(). + Padding(0, 1). + Foreground(lipgloss.Color(colorGray)) + + // ActiveButtonStyle is the style for the active navigation button + ActiveButtonStyle = ButtonStyle. + Background(lipgloss.Color(colorThemePurple)). + Foreground(lipgloss.Color(colorWhite)). + Bold(true) + + // TitleStyle is the style for the app title + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorThemePurple)) + + // DetailStyle is the style for detailed information + DetailStyle = lipgloss.NewStyle(). + PaddingLeft(2). + Foreground(lipgloss.Color(colorDetailGray)) + + // ScrollIndicatorStyle is the style for scroll indicators + ScrollIndicatorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorGray)) + + // FooterStyle is the style for the footer + FooterStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(colorGray)). + Foreground(lipgloss.Color(colorFooterText)). + Padding(0, 1) + + // HelpKeyStyle is the style for keyboard shortcuts in help + HelpKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBlue)). + Bold(true) + + // HelpTextStyle is the style for help text descriptions + HelpTextStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorWhite)) + + // HelpModalStyle is the style for the help modal container + HelpModalStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorThemePurple)). + Padding(1, 2). + Width(45) + + // HelpTitleStyle is the style for the help modal title + HelpTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorThemePurple)). + Align(lipgloss.Center). + Width(28) +) + +// GetMethodStyle returns a styled method label +func GetMethodStyle(method string, highlighted bool) lipgloss.Style { + style := lipgloss.NewStyle(). + Foreground(GetMethodColor(method)). + Bold(true). + Width(7) + + if highlighted { + style = style.Background(lipgloss.Color(colorBackground)) + } + + return style +} + +// GetHighlightStyle returns a style for highlighted items +func GetHighlightStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Background(lipgloss.Color(colorBackground)) +} diff --git a/cmd/openapi/internal/explore/tui/view.go b/cmd/openapi/internal/explore/tui/view.go new file mode 100644 index 0000000..775045b --- /dev/null +++ b/cmd/openapi/internal/explore/tui/view.go @@ -0,0 +1,341 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/speakeasy-api/openapi/cmd/openapi/internal/explore" +) + +// renderHeader renders the application header with navigation +func (m Model) renderHeader() string { + // Build title and nav from config + appTitle := TitleStyle.Render(m.config.Explore.Title) + navSection := ActiveButtonStyle.Render(m.config.Explore.ModeLabel) + + // Calculate spacing + navWidth := lipgloss.Width(navSection) + titleWidth := lipgloss.Width(appTitle) + totalContentWidth := navWidth + titleWidth + + // Create the header line with proper spacing + var headerLine string + if m.width > totalContentWidth+4 { // 4 for some padding + spacingWidth := m.width - totalContentWidth + spacing := strings.Repeat(" ", spacingWidth) + headerLine = navSection + spacing + appTitle + } else { + // If not enough space, just show navigation + headerLine = navSection + } + + // Return header with one empty line below + return headerLine + "\n\n" +} + +// renderFooter renders the application footer with help text and status/doc info +func (m Model) renderFooter() string { + // Use help text from config + helpText := m.config.Explore.FooterHelpText + if m.showHelp { + helpText = "" + } + + // Build status/info based on selection mode + var statusInfo string + if m.config.Selection.Enabled { + // In selection mode, show selected count + selectedCount := len(m.selected) + statusInfo = fmt.Sprintf(m.config.Selection.StatusFormat, selectedCount) + } else { + // In view mode, show doc info + statusInfo = fmt.Sprintf("%s v%s", m.docTitle, m.docVersion) + } + + // Calculate actual content width (accounting for padding in FooterStyle) + // FooterStyle has Padding(0, 1) which adds 2 chars total + contentWidth := m.width - 2 + + // Calculate spacing needed between help text and status + helpTextLen := len(helpText) + statusInfoLen := len(statusInfo) + neededWidth := helpTextLen + statusInfoLen + + var footerContent string + if neededWidth >= contentWidth { + // Not enough space for both - prioritize status + helpText = "" + spacing := strings.Repeat(" ", max(0, contentWidth-len(statusInfo))) + footerContent = spacing + statusInfo + } else { + // Enough space - add spacing between + spacing := strings.Repeat(" ", contentWidth-helpTextLen-statusInfoLen) + footerContent = helpText + spacing + statusInfo + } + + footerStyle := FooterStyle. + Width(m.width). + Align(lipgloss.Left) + + return "\n" + footerStyle.Render(footerContent) +} + +// renderOperations renders the list of operations +func (m Model) renderOperations() string { + var s strings.Builder + + contentHeight := m.calculateContentHeight() + contentWidth := m.calculateContentWidth() + + startIdx := m.scrollOffset + endIdx := min(m.scrollOffset+contentHeight, len(m.operations)) + + // Add scroll indicator for items above + if m.scrollOffset > 0 { + indicator := ScrollIndicatorStyle.Render("⬆ More items above...") + s.WriteString(indicator) + s.WriteString("\n") + } + + for i := startIdx; i < endIdx; i++ { + op := m.operations[i] + isSelected := m.selected[i] + + style := lipgloss.NewStyle() + methodStyle := GetMethodStyle(op.Method, i == m.cursor) + + // Override colors if selected in selection mode + if m.config.Selection.Enabled && isSelected { + selectionColor := lipgloss.Color(m.config.Selection.SelectColor) + style = style.Foreground(selectionColor) + methodStyle = lipgloss.NewStyle(). + Foreground(selectionColor). + Bold(true). + Width(7) + } + + if i == m.cursor { + if m.config.Selection.Enabled && isSelected { + // Keep selection color but add background + style = style.Background(lipgloss.Color(colorBackground)) + methodStyle = methodStyle.Background(lipgloss.Color(colorBackground)) + } else { + style = GetHighlightStyle() + } + } + + // Fold icon (always shown) + foldIcon := "▶" + if !op.Folded { + foldIcon = "▼" + } + + var line strings.Builder + line.WriteString(style.Render(foldIcon + " ")) + + // Selection icon column (only in selection mode) + if m.config.Selection.Enabled { + if isSelected { + line.WriteString(style.Render(m.config.Selection.SelectIcon + " ")) + } else { + // Empty space to keep alignment + line.WriteString(style.Render(" ")) + } + } + + line.WriteString(methodStyle.Render(op.Method)) + line.WriteString(style.Render(" " + op.Path)) + line.WriteString(style.Render(strings.Repeat(" ", contentWidth))) + + s.WriteString(style.Render(line.String())) + s.WriteString("\n") + + // Show details when unfolded (regardless of selection state) + if !op.Folded { + details := m.formatOperationDetails(op) + s.WriteString(DetailStyle.Render(details)) + s.WriteString("\n") + } + } + + // Add scroll indicator for items below + if endIdx < len(m.operations) { + indicator := ScrollIndicatorStyle.Render("⬇ More items below...") + s.WriteString(indicator) + s.WriteString("\n") + } + + return s.String() +} + +// formatOperationDetails formats the detailed information for an operation +func (m Model) formatOperationDetails(op explore.OperationInfo) string { + var details strings.Builder + + if op.Summary != "" { + details.WriteString(fmt.Sprintf("Summary: %s\n", op.Summary)) + } + + if op.Description != "" { + details.WriteString(fmt.Sprintf("Description: %s\n", op.Description)) + } + + if op.OperationID != "" { + details.WriteString(fmt.Sprintf("Operation ID: %s\n", op.OperationID)) + } + + if len(op.Tags) > 0 { + details.WriteString(fmt.Sprintf("Tags: %s\n", strings.Join(op.Tags, ", "))) + } + + if op.Deprecated { + details.WriteString("⚠️ DEPRECATED\n") + } + + // Add parameter information + if params := op.Operation.GetParameters(); len(params) > 0 { + details.WriteString("Parameters:\n") + for _, param := range params { + if param != nil && param.Object != nil { + p := param.Object + required := "" + if p.Required != nil && *p.Required { + required = " (required)" + } + details.WriteString(fmt.Sprintf(" - %s (%s)%s: %s\n", + p.Name, p.In, required, p.GetDescription())) + } + } + } + + // Add request body information + if reqBody := op.Operation.GetRequestBody(); reqBody != nil && reqBody.Object != nil { + details.WriteString("Request Body:\n") + if reqBody.Object.Content != nil { + // Get media types and sort them + var mediaTypes []string + for mediaType := range reqBody.Object.Content.All() { + mediaTypes = append(mediaTypes, mediaType) + } + sort.Strings(mediaTypes) + + for _, mediaType := range mediaTypes { + details.WriteString(fmt.Sprintf(" - %s\n", mediaType)) + } + } + } + + // Add response information + if responses := op.Operation.GetResponses(); responses != nil { + details.WriteString("Responses:\n") + + // Get response codes and sort them + var codes []string + for code := range responses.All() { + codes = append(codes, code) + } + sortResponseCodes(codes) + + for _, code := range codes { + if resp, ok := responses.Get(code); ok && resp != nil && resp.Object != nil { + desc := resp.Object.GetDescription() + if desc != "" { + details.WriteString(fmt.Sprintf(" - %s: %s\n", code, desc)) + } else { + details.WriteString(fmt.Sprintf(" - %s\n", code)) + } + } + } + } + + return details.String() +} + +// sortResponseCodes sorts HTTP response codes with stable ordering +func sortResponseCodes(codes []string) { + sort.Slice(codes, func(i, j int) bool { + // Try to parse as integers + var codeI, codeJ int + _, errI := fmt.Sscanf(codes[i], "%d", &codeI) + _, errJ := fmt.Sscanf(codes[j], "%d", &codeJ) + + // Both are numeric - sort numerically + if errI == nil && errJ == nil { + return codeI < codeJ + } + + // One numeric, one non-numeric - numeric comes first + if errI == nil && errJ != nil { + return true + } + if errI != nil && errJ == nil { + return false + } + + // Both non-numeric - sort alphabetically + return codes[i] < codes[j] + }) +} + +// renderHelpModal renders the help modal overlay +func (m Model) renderHelpModal() string { + // Start with base explore mode commands + helpData := [][]string{ + {"↑/k", "Move up"}, + {"↓/j", "Move down"}, + {"gg", "Move to the top"}, + {"G", "Move to the bottom"}, + {"Ctrl-U", "Scroll up by half a screen"}, + {"Ctrl-D", "Scroll down by half a screen"}, + } + + // Add mode-specific commands + if m.config.Selection.Enabled { + // Selection mode commands + helpData = append(helpData, []string{"Space", "Select/deselect operation"}) + helpData = append(helpData, []string{"a", "Select all operations"}) + helpData = append(helpData, []string{"A", "Deselect all operations"}) + helpData = append(helpData, []string{"Enter/Space", "Toggle details / Select"}) + + // Add action keys (mode-specific commands like "w" for write) + for _, action := range m.config.Selection.ActionKeys { + helpData = append(helpData, []string{action.Key, action.Label}) + } + } else { + // View mode commands + helpData = append(helpData, []string{"Enter/Space", "Toggle details"}) + } + + // Add common commands at the end + helpData = append(helpData, []string{"?", "Toggle help"}) + if m.config.Selection.Enabled { + helpData = append(helpData, []string{"Esc/q", "Cancel and quit"}) + } else { + helpData = append(helpData, []string{"Esc/q", "Close help"}) + } + helpData = append(helpData, []string{"Ctrl+C", "Quit"}) + + // Find max width for first column + maxKeyWidth := 0 + for _, row := range helpData { + if len(row[0]) > maxKeyWidth { + maxKeyWidth = len(row[0]) + } + } + + var helpItems []string + for _, row := range helpData { + key := HelpKeyStyle.Render(fmt.Sprintf("%-*s", maxKeyWidth, row[0])) + desc := HelpTextStyle.Render(" " + row[1]) + helpItems = append(helpItems, key+desc) + } + + helpContent := strings.Join(helpItems, "\n") + + title := HelpTitleStyle.Render(m.config.Explore.HelpTitle) + modal := HelpModalStyle.Render(title + "\n\n" + helpContent) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) +} diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go index c0b9795..1541b1e 100644 --- a/cmd/openapi/main.go +++ b/cmd/openapi/main.go @@ -6,9 +6,9 @@ import ( "runtime/debug" "strings" - arazzoCmd "github.com/speakeasy-api/openapi/arazzo/cmd" - openapiCmd "github.com/speakeasy-api/openapi/openapi/cmd" - overlayCmd "github.com/speakeasy-api/openapi/overlay/cmd" + arazzoCmd "github.com/speakeasy-api/openapi/cmd/openapi/commands/arazzo" + openapiCmd "github.com/speakeasy-api/openapi/cmd/openapi/commands/openapi" + overlayCmd "github.com/speakeasy-api/openapi/cmd/openapi/commands/overlay" "github.com/spf13/cobra" ) diff --git a/go.mod b/go.mod index d75a517..98f9c87 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.24.3 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/speakeasy-api/jsonpath v0.6.2 - github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 github.com/vmware-labs/yaml-jsonpath v0.3.2 golang.org/x/sync v0.17.0 @@ -16,12 +15,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sys v0.36.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 814c2fa..46baeda 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,3 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -12,8 +11,6 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -34,17 +31,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ= github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -59,8 +51,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= diff --git a/go.work b/go.work new file mode 100644 index 0000000..fd75dc0 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.24.3 + +use ( + . + ./cmd/openapi +) diff --git a/mise-tasks/test b/mise-tasks/test index f162674..8dee87e 100755 --- a/mise-tasks/test +++ b/mise-tasks/test @@ -18,7 +18,7 @@ else gotestsum --format testname -- -race ./... echo "🧪 Running tests in separate modules..." - (cd jsonschema/oas3/tests && gotestsum --format testname -- -race ./...) + (cd jsonschema/oas3/tests && GOWORK=off gotestsum --format testname -- -race ./...) fi echo "✅ All tests passed!" \ No newline at end of file diff --git a/openapi/snip.go b/openapi/snip.go new file mode 100644 index 0000000..f9159c0 --- /dev/null +++ b/openapi/snip.go @@ -0,0 +1,169 @@ +package openapi + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// OperationIdentifier uniquely identifies an operation either by operationId or by path and HTTP method. +// Either OperationID should be set, OR both Path and Method should be set. +type OperationIdentifier struct { + // OperationID identifies the operation by its unique operationId + OperationID string + // Path is the endpoint path (e.g., "/users/{id}") + Path string + // Method is the HTTP method (e.g., "GET", "POST", "DELETE") + Method string +} + +// Snip removes specified operations from an OpenAPI document and cleans up unused components. +// +// This function removes the specified operations from the document's paths and then automatically +// runs Clean() to remove any components that are no longer referenced after the operations are removed. +// +// Why use Snip? +// +// - **Reduce API surface**: Remove deprecated or unwanted operations from specifications +// - **Create filtered specs**: Generate subsets of your API for specific use cases +// - **Clean up documentation**: Remove internal-only endpoints before publishing +// - **Prepare for migration**: Remove old endpoints when planning API changes +// - **Generate client SDKs**: Create focused specifications for specific client needs +// +// What Snip does: +// +// 1. Removes each specified operation from its path item +// 2. Removes path items that become empty after operation removal +// 3. Automatically runs Clean() to remove unused components +// +// The operations to remove are specified by path and HTTP method. If all operations +// are removed from a path, the entire path item is removed from the document. +// +// Example usage: +// +// // Define operations to remove by path and method +// operationsToRemove := []OperationIdentifier{ +// {Path: "/users/{id}", Method: "DELETE"}, +// {Path: "/admin/debug", Method: "GET"}, +// } +// +// // Or by operationId +// operationsToRemove := []OperationIdentifier{ +// {OperationID: "deleteUser"}, +// {OperationID: "getDebugInfo"}, +// } +// +// // Remove operations and clean up (modifies doc in place) +// removed, err := Snip(ctx, doc, operationsToRemove) +// if err != nil { +// return fmt.Errorf("failed to snip operations: %w", err) +// } +// +// fmt.Printf("Removed %d operations\n", removed) +// // doc now has the specified operations removed and unused components cleaned up +// +// Parameters: +// - ctx: Context for the operation +// - doc: The OpenAPI document to modify (modified in place) +// - operations: Slice of OperationIdentifier specifying which operations to remove +// +// Returns: +// - int: Number of operations actually removed +// - error: Any error that occurred during the operation +func Snip(ctx context.Context, doc *OpenAPI, operations []OperationIdentifier) (int, error) { + if doc == nil { + return 0, errors.New("document cannot be nil") + } + + if doc.Paths == nil || doc.Paths.Len() == 0 { + return 0, nil // Nothing to remove + } + + if len(operations) == 0 { + return 0, nil // Nothing to remove + } + + removedCount := 0 + + // Remove each specified operation + for _, op := range operations { + // If OperationID is specified, find by ID first + if op.OperationID != "" { + if removed := removeOperationByID(doc, op.OperationID); removed { + removedCount++ + } + } else if op.Path != "" && op.Method != "" { + // Otherwise use path and method + if removed := removeOperation(doc, op.Path, op.Method); removed { + removedCount++ + } + } + } + + // Clean up unused components after removing operations + if err := Clean(ctx, doc); err != nil { + return removedCount, fmt.Errorf("failed to clean unused components: %w", err) + } + + return removedCount, nil +} + +// removeOperationByID removes an operation by its operationId +// Returns true if the operation was found and removed, false otherwise +func removeOperationByID(doc *OpenAPI, operationID string) bool { + if doc.Paths == nil { + return false + } + + // Search through all paths and operations to find matching operationId + for path, pathItem := range doc.Paths.All() { + if pathItem == nil || pathItem.Object == nil { + continue + } + + // Check each HTTP method in this path + for method, operation := range pathItem.Object.All() { + if operation != nil && operation.GetOperationID() == operationID { + // Found it - remove this operation + return removeOperation(doc, path, string(method)) + } + } + } + + return false +} + +// removeOperation removes a single operation from the document by path and method +// Returns true if the operation was found and removed, false otherwise +func removeOperation(doc *OpenAPI, path, method string) bool { + if doc.Paths == nil { + return false + } + + // Get the path item + pathItem, exists := doc.Paths.Get(path) + if !exists || pathItem == nil || pathItem.Object == nil { + return false + } + + // Convert method string to HTTPMethod type (lowercase to match constants) + httpMethod := HTTPMethod(strings.ToLower(method)) + + // Check if the operation exists + operation := pathItem.Object.GetOperation(httpMethod) + if operation == nil { + return false + } + + // Remove the operation from the embedded map + // We need to access the Map field directly since PathItem has its own Delete() method + pathItem.Object.Map.Delete(httpMethod) + + // If the path item has no more operations, remove the entire path + if pathItem.Object.Len() == 0 { + doc.Paths.Delete(path) + } + + return true +} diff --git a/openapi/snip_test.go b/openapi/snip_test.go new file mode 100644 index 0000000..ee85d7f --- /dev/null +++ b/openapi/snip_test.go @@ -0,0 +1,123 @@ +package openapi_test + +import ( + "bytes" + "os" + "testing" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSnip_RemoveOperation_Success(t *testing.T) { + t.Parallel() + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/snip/snip_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "input document should be valid") + + // Remove DELETE /users operation (also removes UnusedSchema via Clean) + removed, err := openapi.Snip(ctx, inputDoc, []openapi.OperationIdentifier{ + {Path: "/users", Method: "DELETE"}, + }) + require.NoError(t, err) + assert.Equal(t, 1, removed, "should remove 1 operation") + + // Marshal the snipped document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/snip/snip_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "snipped document should match expected output") +} + +func TestSnip_RemoveByOperationID_Success(t *testing.T) { + t.Parallel() + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/snip/snip_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, _, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + + // Remove by operation ID + removed, err := openapi.Snip(ctx, inputDoc, []openapi.OperationIdentifier{ + {OperationID: "deleteAllUsers"}, + }) + require.NoError(t, err) + assert.Equal(t, 1, removed, "should remove 1 operation by ID") + + // Verify the operation was removed + usersPath, exists := inputDoc.Paths.Get("/users") + require.True(t, exists, "should keep /users path") + assert.Nil(t, usersPath.Object.GetOperation(openapi.HTTPMethodDelete), "DELETE should be removed") + assert.NotNil(t, usersPath.Object.GetOperation(openapi.HTTPMethodGet), "GET should remain") + assert.NotNil(t, usersPath.Object.GetOperation(openapi.HTTPMethodPost), "POST should remain") +} + +func TestSnip_NonExistentOperation_NoError(t *testing.T) { + t.Parallel() + ctx := t.Context() + + inputFile, err := os.Open("testdata/snip/snip_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, _, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + + // Try to remove non-existent operations + removed, err := openapi.Snip(ctx, inputDoc, []openapi.OperationIdentifier{ + {Path: "/nonexistent", Method: "GET"}, + {OperationID: "nonExistentID"}, + }) + + require.NoError(t, err, "should not error on non-existent operations") + assert.Equal(t, 0, removed, "should remove 0 operations") +} + +func TestSnip_EmptyOperationList_NoError(t *testing.T) { + t.Parallel() + ctx := t.Context() + + inputFile, err := os.Open("testdata/snip/snip_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, _, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + + // Snip with empty list + removed, err := openapi.Snip(ctx, inputDoc, []openapi.OperationIdentifier{}) + require.NoError(t, err) + assert.Equal(t, 0, removed, "should remove 0 operations with empty list") +} + +func TestSnip_NilDocument_Error(t *testing.T) { + t.Parallel() + ctx := t.Context() + + removed, err := openapi.Snip(ctx, nil, []openapi.OperationIdentifier{ + {Path: "/users", Method: "GET"}, + }) + + require.Error(t, err, "should error on nil document") + assert.Equal(t, 0, removed) + assert.Contains(t, err.Error(), "document cannot be nil") +} diff --git a/openapi/testdata/snip/snip_expected.yaml b/openapi/testdata/snip/snip_expected.yaml new file mode 100644 index 0000000..6b11c7d --- /dev/null +++ b/openapi/testdata/snip/snip_expected.yaml @@ -0,0 +1,62 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + operationId: getUsers + summary: Get all users + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/UserList" + post: + operationId: createUser + summary: Create a user + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "201": + description: Created + /pets: + get: + operationId: getPets + summary: Get all pets + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/PetList" +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string + UserList: + type: array + items: + $ref: "#/components/schemas/User" + Pet: + type: object + properties: + id: + type: string + name: + type: string + PetList: + type: array + items: + $ref: "#/components/schemas/Pet" diff --git a/openapi/testdata/snip/snip_input.yaml b/openapi/testdata/snip/snip_input.yaml new file mode 100644 index 0000000..e904017 --- /dev/null +++ b/openapi/testdata/snip/snip_input.yaml @@ -0,0 +1,71 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + operationId: getUsers + summary: Get all users + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/UserList" + post: + operationId: createUser + summary: Create a user + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "201": + description: Created + delete: + operationId: deleteAllUsers + summary: Delete all users + responses: + "204": + description: No content + /pets: + get: + operationId: getPets + summary: Get all pets + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/PetList" +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string + UserList: + type: array + items: + $ref: "#/components/schemas/User" + Pet: + type: object + properties: + id: + type: string + name: + type: string + PetList: + type: array + items: + $ref: "#/components/schemas/Pet" + UnusedSchema: + type: object + description: This schema is not referenced anywhere