Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ The CLI provides three main command groups:
- **`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
- `clean` - Remove unused components and unused top-level tags 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
Expand Down
16 changes: 10 additions & 6 deletions cmd/openapi/commands/openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ paths:

### `clean`

Remove unused components from an OpenAPI specification to create a cleaner, more maintainable document.
Remove unused components and unused top‑level tags from an OpenAPI specification using reachability seeded from /paths and top‑level security.

```bash
# Clean to stdout (pipe-friendly)
Expand All @@ -150,10 +150,12 @@ openapi spec clean -w ./spec.yaml

What cleaning does:

- Removes unused components from all component types (schemas, responses, parameters, etc.)
- Tracks all references throughout the document including `$ref` and security scheme name references
- Preserves all components that are actually used in the specification
- Handles complex reference patterns including circular references and nested components
- Performs reachability-based cleanup seeded only from API surface areas (/paths and top‑level security)
- Expands through $ref links to components until a fixed point is reached
- Preserves security schemes referenced by name in security requirement objects (global and operation‑level)
- Removes unused components from all component types (schemas, responses, parameters, examples, request bodies, headers, links, callbacks, path items)
- Removes unused top‑level tags that are not referenced by any operation
- Handles complex reference patterns; only components reachable from the API surface are kept

**Before cleaning:**

Expand Down Expand Up @@ -823,13 +825,15 @@ 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
Expand Down Expand Up @@ -951,7 +955,7 @@ openapi spec clean | \
openapi spec upgrade | \
openapi spec validate

# Alternative: Clean after bundling to remove unused components
# Alternative: Clean after bundling to remove unused components and unused top-level tags
openapi spec bundle ./spec.yaml ./bundled.yaml
openapi spec clean ./bundled.yaml ./clean-bundled.yaml
openapi spec validate ./clean-bundled.yaml
19 changes: 11 additions & 8 deletions cmd/openapi/commands/openapi/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ import (

var cleanCmd = &cobra.Command{
Use: "clean <input-file> [output-file]",
Short: "Remove unused components from an OpenAPI specification",
Long: `Remove unused components from an OpenAPI specification to create a cleaner, more focused document.
Short: "Remove unused components and unused top-level tags from an OpenAPI specification",
Long: `Remove unused components and unused top-level tags from an OpenAPI specification to create a cleaner, more focused document.

This command analyzes an OpenAPI document to identify which components are actually referenced
and removes any unused components, reducing document size and improving clarity.
This command uses reachability-based analysis to keep only what is actually used by the API surface:
- Seeds reachability exclusively from API surface areas: entries under /paths and the top-level security section
- Expands through $ref links across component sections until a fixed point is reached
- Preserves security schemes referenced by name in security requirement objects (global or operation-level)
- Prunes any components that are not reachable from the API surface
- Removes unused top-level tags that are not referenced by any operation

What gets cleaned:
- Unused schemas in components/schemas
- Unused responses in components/responses
- Unused responses in components/responses
- Unused parameters in components/parameters
- Unused examples in components/examples
- Unused request bodies in components/requestBodies
Expand All @@ -29,14 +33,13 @@ What gets cleaned:
- Unused links in components/links
- Unused callbacks in components/callbacks
- Unused path items in components/pathItems
- Unused top-level tags (global tags not referenced by any operation)

Special handling for security schemes:
Security schemes can be referenced in two ways:
1. By $ref (like other components)
2. By name in security requirement objects (global or operation-level)

The clean command correctly handles both cases and preserves security schemes
that are referenced by name in security blocks.
The clean command correctly handles both cases and preserves security schemes that are referenced by name in security blocks.

Benefits of cleaning:
- Reduce document size by removing dead code
Expand Down
158 changes: 147 additions & 11 deletions cmd/openapi/commands/openapi/snip.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import (
)

var (
snipWriteInPlace bool
snipOperationIDs []string
snipOperations []string
snipWriteInPlace bool
snipOperationIDs []string
snipOperations []string
snipKeepOperationIDs []string
snipKeepOperations []string
)

var snipCmd = &cobra.Command{
Expand Down Expand Up @@ -73,6 +75,9 @@ 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)")
// Keep-mode flags (mutually exclusive with remove-mode flags)
snipCmd.Flags().StringSliceVar(&snipKeepOperationIDs, "keepOperationId", nil, "operation ID to keep (can be comma-separated or repeated)")
snipCmd.Flags().StringSliceVar(&snipKeepOperations, "keepOperation", nil, "operation as path:method to keep (can be comma-separated or repeated)")
}

func runSnip(cmd *cobra.Command, args []string) error {
Expand All @@ -84,20 +89,29 @@ func runSnip(cmd *cobra.Command, args []string) error {
outputFile = args[1]
}

// Check if any operations were specified via flags
hasOperationFlags := len(snipOperationIDs) > 0 || len(snipOperations) > 0
// Check which flag sets were specified
hasRemoveFlags := len(snipOperationIDs) > 0 || len(snipOperations) > 0
hasKeepFlags := len(snipKeepOperationIDs) > 0 || len(snipKeepOperations) > 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 -w is specified without any operation selection flags, error
if snipWriteInPlace && !(hasRemoveFlags || hasKeepFlags) {
return fmt.Errorf("--write flag requires specifying operations via --operationId/--operation or --keepOperationId/--keepOperation")
}

if !hasOperationFlags {
// No flags - interactive mode
// Interactive mode when no flags provided
if !hasRemoveFlags && !hasKeepFlags {
return runSnipInteractive(ctx, inputFile, outputFile)
}

// Flags specified - CLI mode
// Disallow mixing keep + remove flags; ambiguous intent
if hasRemoveFlags && hasKeepFlags {
return fmt.Errorf("cannot combine keep and remove flags; use either --operationId/--operation or --keepOperationId/--keepOperation")
}

// CLI mode
if hasKeepFlags {
return runSnipCLIKeep(ctx, inputFile, outputFile)
}
return runSnipCLI(ctx, inputFile, outputFile)
}

Expand Down Expand Up @@ -139,6 +153,87 @@ func runSnipCLI(ctx context.Context, inputFile, outputFile string) error {
return processor.WriteDocument(ctx, doc)
}

func runSnipCLIKeep(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 keep flags
keepOps, err := parseKeepOperationFlags()
if err != nil {
return err
}
if len(keepOps) == 0 {
return fmt.Errorf("no operations specified to keep")
}

// Collect all operations from the document
allOps, err := explore.CollectOperations(ctx, doc)
if err != nil {
return fmt.Errorf("failed to collect operations: %w", err)
}
if len(allOps) == 0 {
return fmt.Errorf("no operations found in the OpenAPI document")
}

// Build lookup sets for keep filters
keepByID := map[string]bool{}
keepByPathMethod := map[string]bool{}
for _, k := range keepOps {
if k.OperationID != "" {
keepByID[k.OperationID] = true
}
if k.Path != "" && k.Method != "" {
key := strings.ToUpper(k.Method) + " " + k.Path
keepByPathMethod[key] = true
}
}

// Compute removal list = all - keep
var operationsToRemove []openapi.OperationIdentifier
for _, op := range allOps {
if op.OperationID != "" && keepByID[op.OperationID] {
continue
}
key := strings.ToUpper(op.Method) + " " + op.Path
if keepByPathMethod[key] {
continue
}
operationsToRemove = append(operationsToRemove, openapi.OperationIdentifier{
Path: op.Path,
Method: strings.ToUpper(op.Method),
})
}

// If nothing to remove, write as-is
if len(operationsToRemove) == 0 {
processor.PrintSuccess("No operations to remove based on keep filters; writing document unchanged")
return processor.WriteDocument(ctx, doc)
}

// 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 kept %d operation(s) and removed %d operation(s) with cleanup", len(allOps)-removed, 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)
Expand Down Expand Up @@ -306,6 +401,47 @@ func parseOperationFlags() ([]openapi.OperationIdentifier, error) {
return operations, nil
}

// parseKeepOperationFlags parses the keep flags into operation identifiers
// Handles both repeated flags and comma-separated values
func parseKeepOperationFlags() ([]openapi.OperationIdentifier, error) {
var operations []openapi.OperationIdentifier

// Parse keep operation IDs
for _, opID := range snipKeepOperationIDs {
if opID != "" {
operations = append(operations, openapi.OperationIdentifier{
OperationID: opID,
})
}
}

// Parse keep path:method operations
for _, op := range snipKeepOperations {
if op == "" {
continue
}

parts := strings.SplitN(op, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid keep 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 keep 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
Expand Down
4 changes: 2 additions & 2 deletions cmd/openapi/internal/explore/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ func NewModelWithConfig(operations []explore.OperationInfo, docTitle, docVersion
// 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) {
for idx, isSelected := range m.selected {
if isSelected && idx < len(m.operations) {
selected = append(selected, m.operations[idx])
}
}
Expand Down
Loading