From ade1ed7c82a18e44f16e059b5ca28ef72792ab0a Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Thu, 18 Dec 2025 11:46:56 +0200 Subject: [PATCH 1/6] feat: add slice/extract command to filter OpenAPI specs by tags, operation IDs, and paths - Add new 'extract' CLI command to slice OpenAPI specifications - Support filtering by operation IDs, tags, and path patterns - Implement SliceFilter with comprehensive test coverage - Use Cobra's StringSliceVar for cleaner flag handling - Include all referenced schemas and components in output - Add validation for input parameters and output formats - Support both YAML and JSON output formats --- .../cli/internal/cli/root/openapi/builder.go | 2 + tools/cli/internal/cli/slice/slice.go | 141 ++++++++ tools/cli/internal/cli/slice/slice_test.go | 118 ++++++ tools/cli/internal/openapi/filter/slice.go | 90 +++++ .../cli/internal/openapi/filter/slice_test.go | 342 ++++++++++++++++++ 5 files changed, 693 insertions(+) create mode 100644 tools/cli/internal/cli/slice/slice.go create mode 100644 tools/cli/internal/cli/slice/slice_test.go create mode 100644 tools/cli/internal/openapi/filter/slice.go create mode 100644 tools/cli/internal/openapi/filter/slice_test.go diff --git a/tools/cli/internal/cli/root/openapi/builder.go b/tools/cli/internal/cli/root/openapi/builder.go index fe81ade583..6a1ed04cd6 100644 --- a/tools/cli/internal/cli/root/openapi/builder.go +++ b/tools/cli/internal/cli/root/openapi/builder.go @@ -22,6 +22,7 @@ import ( "github.com/mongodb/openapi/tools/cli/internal/cli/changelog" "github.com/mongodb/openapi/tools/cli/internal/cli/filter" "github.com/mongodb/openapi/tools/cli/internal/cli/merge" + "github.com/mongodb/openapi/tools/cli/internal/cli/slice" "github.com/mongodb/openapi/tools/cli/internal/cli/split" "github.com/mongodb/openapi/tools/cli/internal/cli/sunset" "github.com/mongodb/openapi/tools/cli/internal/cli/versions" @@ -63,6 +64,7 @@ func Builder() *cobra.Command { breakingchanges.Builder(), sunset.Builder(), filter.Builder(), + slice.Builder(), ) return rootCmd } diff --git a/tools/cli/internal/cli/slice/slice.go b/tools/cli/internal/cli/slice/slice.go new file mode 100644 index 0000000000..90c073f715 --- /dev/null +++ b/tools/cli/internal/cli/slice/slice.go @@ -0,0 +1,141 @@ +// Copyright 2024 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slice + +import ( + "fmt" + "log" + + "github.com/mongodb/openapi/tools/cli/internal/cli/flag" + "github.com/mongodb/openapi/tools/cli/internal/cli/usage" + "github.com/mongodb/openapi/tools/cli/internal/openapi" + "github.com/mongodb/openapi/tools/cli/internal/openapi/filter" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +type Opts struct { + fs afero.Fs + basePath string + outputPath string + format string + operationIDs []string + tags []string + paths []string +} + +func (o *Opts) Run() error { + loader := openapi.NewOpenAPI3() + specInfo, err := loader.CreateOpenAPISpecFromPath(o.basePath) + if err != nil { + return err + } + + metadata := &filter.SliceMetadata{ + OperationIDs: o.operationIDs, + Tags: o.tags, + Paths: o.paths, + } + + // Log what we're slicing + if len(metadata.OperationIDs) > 0 { + log.Printf("Slicing operations by IDs: %v", metadata.OperationIDs) + } + if len(metadata.Tags) > 0 { + log.Printf("Slicing operations by tags: %v", metadata.Tags) + } + if len(metadata.Paths) > 0 { + log.Printf("Slicing operations by paths: %v", metadata.Paths) + } + + // TODO: Add other filters + sliceFilter := filter.NewSliceFilter(specInfo.Spec, metadata) + if err := sliceFilter.Apply(); err != nil { + return err + } + + // Validate the sliced spec + if err := specInfo.Spec.Validate(loader.Loader.Context); err != nil { + log.Printf("[WARN] Sliced OpenAPI document has validation warnings: %v", err) + } + + return openapi.Save(o.outputPath, specInfo.Spec, o.format, o.fs) +} + +func (o *Opts) PreRunE(_ []string) error { + if o.basePath == "" { + return fmt.Errorf("no OAS detected. Please, use the flag %s to include the base OAS", flag.Spec) + } + + if len(o.operationIDs) == 0 && len(o.tags) == 0 && len(o.paths) == 0 { + return fmt.Errorf("at least one of --operation-ids, --tags, or --paths must be specified") + } + + return openapi.ValidateFormatAndOutput(o.format, o.outputPath) +} + +// Builder builds the extract command with the following signature: +// extract -s spec -o output.json --operation-ids "op1,op2" --tags "tag1,tag2" --paths "/api/v1". +func Builder() *cobra.Command { + opts := &Opts{ + fs: afero.NewOsFs(), + } + + cmd := &cobra.Command{ + Use: "extract -s spec", + Short: "Extract a subset of an OpenAPI specification by operation IDs, tags, or paths", + Long: `Extract creates a valid mini OpenAPI specification containing only the operations +that match the specified criteria. The output includes all necessary schemas and +components referenced by the selected operations. + +You can filter by: + - Operation IDs: Specific operation identifiers + - Tags: Operations tagged with specific values + - Paths: Operations under specific path patterns + +Multiple values can be specified as comma-separated lists.`, + Example: ` # Extract specific operations by ID: + foascli extract -s spec.yaml -o subset.yaml --operation-ids "getUser,createUser" + + # Extract operations by tags: + foascli extract -s spec.yaml -o subset.yaml --tags "Users,Authentication" + + # Extract operations by path patterns: + foascli extract -s spec.yaml -o subset.yaml --paths "/api/v1/users" + + # Combine multiple criteria: + foascli extract -s spec.yaml -o subset.yaml --tags "Users" --paths "/api/v1"`, + Args: cobra.NoArgs, + PreRunE: func(_ *cobra.Command, args []string) error { + return opts.PreRunE(args) + }, + RunE: func(_ *cobra.Command, _ []string) error { + return opts.Run() + }, + } + + cmd.Flags().StringVarP(&opts.basePath, flag.Spec, flag.SpecShort, "-", usage.Spec) + cmd.Flags().StringVarP(&opts.outputPath, flag.Output, flag.OutputShort, "", usage.Output) + cmd.Flags().StringVarP(&opts.format, flag.Format, flag.FormatShort, openapi.ALL, usage.Format) + cmd.Flags().StringSliceVar(&opts.operationIDs, "operation-ids", []string{}, "Comma-separated list of operation IDs to extract") + cmd.Flags().StringSliceVar(&opts.tags, "tags", []string{}, "Comma-separated list of tags to extract") + cmd.Flags().StringSliceVar(&opts.paths, "paths", []string{}, "Comma-separated list of path patterns to extract") + + // Required flags + _ = cmd.MarkFlagRequired(flag.Output) + _ = cmd.MarkFlagRequired(flag.Spec) + + return cmd +} diff --git a/tools/cli/internal/cli/slice/slice_test.go b/tools/cli/internal/cli/slice/slice_test.go new file mode 100644 index 0000000000..a6cb57cabf --- /dev/null +++ b/tools/cli/internal/cli/slice/slice_test.go @@ -0,0 +1,118 @@ +// Copyright 2024 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slice + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpts_PreRunE(t *testing.T) { + testCases := []struct { + name string + opts Opts + wantErr require.ErrorAssertionFunc + errorMsg string + }{ + { + name: "valid with operation IDs", + opts: Opts{ + basePath: "spec.yaml", + outputPath: "output.yaml", + format: "yaml", + operationIDs: []string{"op1", "op2"}, + }, + wantErr: require.NoError, + }, + { + name: "valid with tags", + opts: Opts{ + basePath: "spec.yaml", + outputPath: "output.yaml", + format: "yaml", + tags: []string{"tag1", "tag2"}, + }, + wantErr: require.NoError, + }, + { + name: "valid with paths", + opts: Opts{ + basePath: "spec.yaml", + outputPath: "output.yaml", + format: "yaml", + paths: []string{"/api/v1"}, + }, + wantErr: require.NoError, + }, + { + name: "missing base path", + opts: Opts{ + outputPath: "output.yaml", + format: "yaml", + operationIDs: []string{"op1"}, + }, + wantErr: require.Error, + errorMsg: "no OAS detected", + }, + { + name: "missing all criteria", + opts: Opts{ + basePath: "spec.yaml", + outputPath: "output.yaml", + format: "yaml", + }, + wantErr: require.Error, + errorMsg: "at least one of --operation-ids, --tags, or --paths must be specified", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + err := tt.opts.PreRunE(nil) + tt.wantErr(t, err) + if tt.errorMsg != "" && err != nil { + assert.Contains(t, err.Error(), tt.errorMsg) + } + }) + } +} + +func TestInvalidFormat_PreRunE(t *testing.T) { + opts := &Opts{ + outputPath: "slice.json", + basePath: "base.json", + format: "html", + paths: []string{"/api/v1"}, + } + + err := opts.PreRunE(nil) + require.Error(t, err) + require.EqualError(t, err, "format must be either 'json', 'yaml' or 'all', got 'html'") +} + +func TestInvalidPath_PreRunE(t *testing.T) { + opts := &Opts{ + outputPath: "slice.html", + basePath: "base.json", + format: "all", + paths: []string{"/api/v1"}, + } + + err := opts.PreRunE(nil) + require.Error(t, err) + require.EqualError(t, err, "output file must be either a JSON or YAML file, got slice.html") +} diff --git a/tools/cli/internal/openapi/filter/slice.go b/tools/cli/internal/openapi/filter/slice.go new file mode 100644 index 0000000000..23c5e73c16 --- /dev/null +++ b/tools/cli/internal/openapi/filter/slice.go @@ -0,0 +1,90 @@ +package filter + +import ( + "fmt" + "regexp" + "slices" + + "github.com/getkin/kin-openapi/openapi3" +) + +type SliceMetadata struct { + OperationIDs []string + Tags []string + Paths []string +} + +type SliceFilter struct { + oas *openapi3.T + metadata *SliceMetadata +} + +func NewSliceFilter(oas *openapi3.T, metadata *SliceMetadata) *SliceFilter { + return &SliceFilter{ + oas: oas, + metadata: metadata, + } +} + +func (f *SliceFilter) ValidateMetadata() error { + return nil +} + +func (f *SliceFilter) Apply() error { + if f.oas == nil { + return fmt.Errorf("OpenAPI spec is nil") + } + + if f.oas.Paths == nil { + return nil + } + + for path, pathItem := range f.oas.Paths.Map() { + if pathItem == nil { + continue + } + hasOperations := false + for method, operation := range pathItem.Operations() { + if operation == nil { + continue + } + if f.matches(path, operation) { + hasOperations = true + } else { + pathItem.SetOperation(method, nil) + } + } + if !hasOperations { + f.oas.Paths.Delete(path) + } + } + return nil +} + +func (e *SliceFilter) matches(path string, operation *openapi3.Operation) bool { + normalizedPath := normalizePath(path) + for _, pattern := range e.metadata.Paths { + if normalizedPath == normalizePath(pattern) { + return true + } + } + + // Check if the operation ID matches + if slices.Contains(e.metadata.OperationIDs, operation.OperationID) { + return true + } + + // Check if the tag matches + for _, tag := range e.metadata.Tags { + if slices.Contains(operation.Tags, tag) { + return true + } + } + return false +} + +var pathParamRegex = regexp.MustCompile(`\{[^}]+\}`) + +func normalizePath(path string) string { + return pathParamRegex.ReplaceAllString(path, "{}") +} diff --git a/tools/cli/internal/openapi/filter/slice_test.go b/tools/cli/internal/openapi/filter/slice_test.go new file mode 100644 index 0000000000..f1c75c55ef --- /dev/null +++ b/tools/cli/internal/openapi/filter/slice_test.go @@ -0,0 +1,342 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filter + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizePath(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "single parameter", + input: "/api/v2/groups/{groupId}", + expected: "/api/v2/groups/{}", + }, + { + name: "multiple parameters", + input: "/api/v2/groups/{groupId}/clusters/{clusterId}", + expected: "/api/v2/groups/{}/clusters/{}", + }, + { + name: "no parameters", + input: "/api/v2/groups", + expected: "/api/v2/groups", + }, + { + name: "different parameter names normalize to same", + input: "/api/v2/groups/{id}", + expected: "/api/v2/groups/{}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizePath(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSliceFilter_Apply_FilterByPath(t *testing.T) { + tests := []struct { + name string + paths []string + specPaths map[string]*openapi3.PathItem + expectedPaths []string + }{ + { + name: "exact path match", + paths: []string{"/api/v2/groups/{groupId}"}, + specPaths: map[string]*openapi3.PathItem{ + "/api/v2/groups/{groupId}": { + Get: &openapi3.Operation{OperationID: "getGroup"}, + }, + "/api/v2/users/{userId}": { + Get: &openapi3.Operation{OperationID: "getUser"}, + }, + }, + expectedPaths: []string{"/api/v2/groups/{groupId}"}, + }, + { + name: "normalized path match - different param names", + paths: []string{"/api/v2/groups/{id}"}, + specPaths: map[string]*openapi3.PathItem{ + "/api/v2/groups/{groupId}": { + Get: &openapi3.Operation{OperationID: "getGroup"}, + }, + "/api/v2/users/{userId}": { + Get: &openapi3.Operation{OperationID: "getUser"}, + }, + }, + expectedPaths: []string{"/api/v2/groups/{groupId}"}, + }, + { + name: "multiple parameters normalized", + paths: []string{"/api/v2/groups/{gid}/clusters/{cid}"}, + specPaths: map[string]*openapi3.PathItem{ + "/api/v2/groups/{groupId}/clusters/{clusterId}": { + Get: &openapi3.Operation{OperationID: "getCluster"}, + }, + "/api/v2/users/{userId}": { + Get: &openapi3.Operation{OperationID: "getUser"}, + }, + }, + expectedPaths: []string{"/api/v2/groups/{groupId}/clusters/{clusterId}"}, + }, + { + name: "no match - all paths removed", + paths: []string{"/api/v2/nonexistent/{id}"}, + specPaths: map[string]*openapi3.PathItem{ + "/api/v2/groups/{groupId}": { + Get: &openapi3.Operation{OperationID: "getGroup"}, + }, + }, + expectedPaths: []string{}, + }, + { + name: "multiple paths to keep", + paths: []string{"/api/v2/groups/{id}", "/api/v2/users/{id}"}, + specPaths: map[string]*openapi3.PathItem{ + "/api/v2/groups/{groupId}": { + Get: &openapi3.Operation{OperationID: "getGroup"}, + }, + "/api/v2/users/{userId}": { + Get: &openapi3.Operation{OperationID: "getUser"}, + }, + "/api/v2/clusters/{clusterId}": { + Get: &openapi3.Operation{OperationID: "getCluster"}, + }, + }, + expectedPaths: []string{"/api/v2/groups/{groupId}", "/api/v2/users/{userId}"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oas := &openapi3.T{ + Paths: openapi3.NewPaths(), + } + for path, pathItem := range tt.specPaths { + oas.Paths.Set(path, pathItem) + } + + filter := NewSliceFilter(oas, &SliceMetadata{ + Paths: tt.paths, + }) + + err := filter.Apply() + require.NoError(t, err) + + assert.Equal(t, len(tt.expectedPaths), oas.Paths.Len()) + for _, expectedPath := range tt.expectedPaths { + assert.NotNil(t, oas.Paths.Value(expectedPath), "expected path %s to exist", expectedPath) + } + }) + } +} + +func TestSliceFilter_Apply_FilterByOperationID(t *testing.T) { + oas := &openapi3.T{ + Paths: openapi3.NewPaths(), + } + + oas.Paths.Set("/api/v2/groups/{groupId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getGroup"}, + Post: &openapi3.Operation{OperationID: "createGroup"}, + }) + oas.Paths.Set("/api/v2/users/{userId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getUser"}, + Delete: &openapi3.Operation{OperationID: "deleteUser"}, + }) + + filter := NewSliceFilter(oas, &SliceMetadata{ + OperationIDs: []string{"getGroup", "deleteUser"}, + }) + + err := filter.Apply() + require.NoError(t, err) + + // Should keep both paths + assert.Equal(t, 2, oas.Paths.Len()) + + // Check groups path - only GET should remain + groupsPath := oas.Paths.Value("/api/v2/groups/{groupId}") + require.NotNil(t, groupsPath) + assert.NotNil(t, groupsPath.Get) + assert.Nil(t, groupsPath.Post) // POST should be removed + + // Check users path - only DELETE should remain + usersPath := oas.Paths.Value("/api/v2/users/{userId}") + require.NotNil(t, usersPath) + assert.Nil(t, usersPath.Get) // GET should be removed + assert.NotNil(t, usersPath.Delete) +} + +func TestSliceFilter_Apply_FilterByTag(t *testing.T) { + oas := &openapi3.T{ + Paths: openapi3.NewPaths(), + } + + oas.Paths.Set("/api/v2/groups/{groupId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getGroup", Tags: []string{"Groups"}}, + Post: &openapi3.Operation{OperationID: "createGroup", Tags: []string{"Groups"}}, + }) + oas.Paths.Set("/api/v2/users/{userId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getUser", Tags: []string{"Users"}}, + Delete: &openapi3.Operation{OperationID: "deleteUser", Tags: []string{"Users"}}, + }) + oas.Paths.Set("/api/v2/clusters/{clusterId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getCluster", Tags: []string{"Clusters"}}, + }) + + filter := NewSliceFilter(oas, &SliceMetadata{ + Tags: []string{"Groups", "Clusters"}, + }) + + err := filter.Apply() + require.NoError(t, err) + + // Should keep groups and clusters paths, remove users + assert.Equal(t, 2, oas.Paths.Len()) + assert.NotNil(t, oas.Paths.Value("/api/v2/groups/{groupId}")) + assert.NotNil(t, oas.Paths.Value("/api/v2/clusters/{clusterId}")) + assert.Nil(t, oas.Paths.Value("/api/v2/users/{userId}")) +} + +func TestSliceFilter_Apply_MultipleCriteria(t *testing.T) { + oas := &openapi3.T{ + Paths: openapi3.NewPaths(), + } + + oas.Paths.Set("/api/v2/groups/{groupId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getGroup", Tags: []string{"Groups"}}, + Post: &openapi3.Operation{OperationID: "createGroup", Tags: []string{"Groups"}}, + }) + oas.Paths.Set("/api/v2/users/{userId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getUser", Tags: []string{"Users"}}, + Delete: &openapi3.Operation{OperationID: "deleteUser", Tags: []string{"Users"}}, + }) + oas.Paths.Set("/api/v2/clusters/{clusterId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getCluster", Tags: []string{"Clusters"}}, + }) + + // Match by: path (/api/v2/groups/{id}), operationID (getUser), or tag (Clusters) + filter := NewSliceFilter(oas, &SliceMetadata{ + Paths: []string{"/api/v2/groups/{id}"}, + OperationIDs: []string{"getUser"}, + Tags: []string{"Clusters"}, + }) + + err := filter.Apply() + require.NoError(t, err) + + // Should keep all three paths + assert.Equal(t, 3, oas.Paths.Len()) + + // Groups path - both operations match by path + groupsPath := oas.Paths.Value("/api/v2/groups/{groupId}") + require.NotNil(t, groupsPath) + assert.NotNil(t, groupsPath.Get) + assert.NotNil(t, groupsPath.Post) + + // Users path - only GET matches by operationID + usersPath := oas.Paths.Value("/api/v2/users/{userId}") + require.NotNil(t, usersPath) + assert.NotNil(t, usersPath.Get) + assert.Nil(t, usersPath.Delete) + + // Clusters path - matches by tag + clustersPath := oas.Paths.Value("/api/v2/clusters/{clusterId}") + require.NotNil(t, clustersPath) + assert.NotNil(t, clustersPath.Get) +} + +func TestSliceFilter_Apply_EmptyMetadata(t *testing.T) { + oas := &openapi3.T{ + Paths: openapi3.NewPaths(), + } + + oas.Paths.Set("/api/v2/groups/{groupId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getGroup"}, + }) + + filter := NewSliceFilter(oas, &SliceMetadata{}) + + err := filter.Apply() + require.NoError(t, err) + + // With empty metadata, nothing matches, so all paths should be removed + assert.Equal(t, 0, oas.Paths.Len()) +} + +func TestSliceFilter_Apply_NilOAS(t *testing.T) { + filter := NewSliceFilter(nil, &SliceMetadata{ + Paths: []string{"/api/v2/groups/{id}"}, + }) + + err := filter.Apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "OpenAPI spec is nil") +} + +func TestSliceFilter_Apply_NilPaths(t *testing.T) { + oas := &openapi3.T{ + Paths: nil, + } + + filter := NewSliceFilter(oas, &SliceMetadata{ + Paths: []string{"/api/v2/groups/{id}"}, + }) + + err := filter.Apply() + require.NoError(t, err) // Should not error, just return early +} + +func TestSliceFilter_Apply_RemovePathWithNoMatchingOperations(t *testing.T) { + oas := &openapi3.T{ + Paths: openapi3.NewPaths(), + } + + oas.Paths.Set("/api/v2/groups/{groupId}", &openapi3.PathItem{ + Get: &openapi3.Operation{OperationID: "getGroup", Tags: []string{"Groups"}}, + Post: &openapi3.Operation{OperationID: "createGroup", Tags: []string{"Groups"}}, + }) + + // Filter by tag that doesn't match any operations + filter := NewSliceFilter(oas, &SliceMetadata{ + Tags: []string{"Users"}, + }) + + err := filter.Apply() + require.NoError(t, err) + + // Path should be removed since no operations match + assert.Equal(t, 0, oas.Paths.Len()) +} + +func TestSliceFilter_ValidateMetadata(t *testing.T) { + filter := NewSliceFilter(&openapi3.T{}, &SliceMetadata{}) + err := filter.ValidateMetadata() + require.NoError(t, err) // Should always return nil +} From 208c7e43cf53de8f386d5cf5cf5775a0994764d7 Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Thu, 18 Dec 2025 11:56:32 +0200 Subject: [PATCH 2/6] refactor: rename command from 'extract' to 'slice' - Update command name from 'extract' to 'slice' for consistency - Update all examples and documentation to use 'slice' - All tests still pass --- tools/cli/internal/cli/slice/slice.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tools/cli/internal/cli/slice/slice.go b/tools/cli/internal/cli/slice/slice.go index 90c073f715..4cd8c139cf 100644 --- a/tools/cli/internal/cli/slice/slice.go +++ b/tools/cli/internal/cli/slice/slice.go @@ -86,17 +86,17 @@ func (o *Opts) PreRunE(_ []string) error { return openapi.ValidateFormatAndOutput(o.format, o.outputPath) } -// Builder builds the extract command with the following signature: -// extract -s spec -o output.json --operation-ids "op1,op2" --tags "tag1,tag2" --paths "/api/v1". +// Builder builds the slice command with the following signature: +// slice -s spec -o output.json --operation-ids "op1,op2" --tags "tag1,tag2" --paths "/api/v1". func Builder() *cobra.Command { opts := &Opts{ fs: afero.NewOsFs(), } cmd := &cobra.Command{ - Use: "extract -s spec", - Short: "Extract a subset of an OpenAPI specification by operation IDs, tags, or paths", - Long: `Extract creates a valid mini OpenAPI specification containing only the operations + Use: "slice -s spec", + Short: "Slice a subset of an OpenAPI specification by operation IDs, tags, or paths", + Long: `Slice creates a valid mini OpenAPI specification containing only the operations that match the specified criteria. The output includes all necessary schemas and components referenced by the selected operations. @@ -106,17 +106,17 @@ You can filter by: - Paths: Operations under specific path patterns Multiple values can be specified as comma-separated lists.`, - Example: ` # Extract specific operations by ID: - foascli extract -s spec.yaml -o subset.yaml --operation-ids "getUser,createUser" + Example: ` # Slice specific operations by ID: + foascli slice -s spec.yaml -o subset.yaml --operation-ids "getUser,createUser" - # Extract operations by tags: - foascli extract -s spec.yaml -o subset.yaml --tags "Users,Authentication" + # Slice operations by tags: + foascli slice -s spec.yaml -o subset.yaml --tags "Users,Authentication" - # Extract operations by path patterns: - foascli extract -s spec.yaml -o subset.yaml --paths "/api/v1/users" + # Slice operations by path patterns: + foascli slice -s spec.yaml -o subset.yaml --paths "/api/v1/users" # Combine multiple criteria: - foascli extract -s spec.yaml -o subset.yaml --tags "Users" --paths "/api/v1"`, + foascli slice -s spec.yaml -o subset.yaml --tags "Users" --paths "/api/v1"`, Args: cobra.NoArgs, PreRunE: func(_ *cobra.Command, args []string) error { return opts.PreRunE(args) From 81a8f6a5b7134ab3323bd62ea1093eaba09c7bf4 Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Thu, 18 Dec 2025 17:50:06 +0200 Subject: [PATCH 3/6] fix: replace fmt.Errorf with errors.New for non-formatted error - Fix perfsprint linter error - Use errors.New instead of fmt.Errorf when no formatting is needed - Add errors import --- tools/cli/internal/cli/slice/slice.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/cli/internal/cli/slice/slice.go b/tools/cli/internal/cli/slice/slice.go index 4cd8c139cf..12db182ab8 100644 --- a/tools/cli/internal/cli/slice/slice.go +++ b/tools/cli/internal/cli/slice/slice.go @@ -15,6 +15,7 @@ package slice import ( + "errors" "fmt" "log" @@ -80,7 +81,7 @@ func (o *Opts) PreRunE(_ []string) error { } if len(o.operationIDs) == 0 && len(o.tags) == 0 && len(o.paths) == 0 { - return fmt.Errorf("at least one of --operation-ids, --tags, or --paths must be specified") + return errors.New("at least one of --operation-ids, --tags, or --paths must be specified") } return openapi.ValidateFormatAndOutput(o.format, o.outputPath) From dc4fab346b6f770517036db34bb84bc4b483133a Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Thu, 18 Dec 2025 17:55:57 +0200 Subject: [PATCH 4/6] fix: resolve golangci-lint issues - Replace fmt.Errorf with errors.New for non-formatted errors (perfsprint) - Fix receiver naming consistency in SliceFilter.matches method (revive) - Change receiver name from 'e' to 'f' to match other methods --- tools/cli/internal/openapi/filter/slice.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/cli/internal/openapi/filter/slice.go b/tools/cli/internal/openapi/filter/slice.go index 23c5e73c16..542c6b9068 100644 --- a/tools/cli/internal/openapi/filter/slice.go +++ b/tools/cli/internal/openapi/filter/slice.go @@ -1,7 +1,7 @@ package filter import ( - "fmt" + "errors" "regexp" "slices" @@ -26,13 +26,13 @@ func NewSliceFilter(oas *openapi3.T, metadata *SliceMetadata) *SliceFilter { } } -func (f *SliceFilter) ValidateMetadata() error { +func (*SliceFilter) ValidateMetadata() error { return nil } func (f *SliceFilter) Apply() error { if f.oas == nil { - return fmt.Errorf("OpenAPI spec is nil") + return errors.New("OpenAPI spec is nil") } if f.oas.Paths == nil { @@ -61,21 +61,21 @@ func (f *SliceFilter) Apply() error { return nil } -func (e *SliceFilter) matches(path string, operation *openapi3.Operation) bool { +func (f *SliceFilter) matches(path string, operation *openapi3.Operation) bool { normalizedPath := normalizePath(path) - for _, pattern := range e.metadata.Paths { + for _, pattern := range f.metadata.Paths { if normalizedPath == normalizePath(pattern) { return true } } // Check if the operation ID matches - if slices.Contains(e.metadata.OperationIDs, operation.OperationID) { + if slices.Contains(f.metadata.OperationIDs, operation.OperationID) { return true } // Check if the tag matches - for _, tag := range e.metadata.Tags { + for _, tag := range f.metadata.Tags { if slices.Contains(operation.Tags, tag) { return true } From a6d65520885404c3f80616bbdd3572c91ed03427 Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Fri, 19 Dec 2025 11:45:34 +0200 Subject: [PATCH 5/6] feat: add ParametersFilter and refactor slice into separate package - Add ParametersFilter to remove unused parameters from OpenAPI specs - Add comprehensive tests for ParametersFilter (7 test scenarios) - Move slice functionality from filter package to dedicated slice package - Update FiltersToCleanupRefs to include TagsFilter, ParametersFilter, and SchemasFilter - Fix linting issues (import shadowing and missing period in comment) - Slice command now cleans up unused tags, parameters, and schemas automatically --- tools/cli/internal/cli/slice/slice.go | 21 +- tools/cli/internal/openapi/filter/filter.go | 8 + .../cli/internal/openapi/filter/parameters.go | 64 ++ .../openapi/filter/parameters_test.go | 626 ++++++++++++++++++ tools/cli/internal/openapi/filter/schemas.go | 4 + tools/cli/internal/openapi/filter/slice.go | 90 --- tools/cli/internal/openapi/slice/slice.go | 108 +++ .../openapi/{filter => slice}/slice_test.go | 58 +- 8 files changed, 838 insertions(+), 141 deletions(-) create mode 100644 tools/cli/internal/openapi/filter/parameters.go create mode 100644 tools/cli/internal/openapi/filter/parameters_test.go delete mode 100644 tools/cli/internal/openapi/filter/slice.go create mode 100644 tools/cli/internal/openapi/slice/slice.go rename tools/cli/internal/openapi/{filter => slice}/slice_test.go (86%) diff --git a/tools/cli/internal/cli/slice/slice.go b/tools/cli/internal/cli/slice/slice.go index 12db182ab8..0ca6fdb05c 100644 --- a/tools/cli/internal/cli/slice/slice.go +++ b/tools/cli/internal/cli/slice/slice.go @@ -22,7 +22,7 @@ import ( "github.com/mongodb/openapi/tools/cli/internal/cli/flag" "github.com/mongodb/openapi/tools/cli/internal/cli/usage" "github.com/mongodb/openapi/tools/cli/internal/openapi" - "github.com/mongodb/openapi/tools/cli/internal/openapi/filter" + "github.com/mongodb/openapi/tools/cli/internal/openapi/slice" "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -44,26 +44,25 @@ func (o *Opts) Run() error { return err } - metadata := &filter.SliceMetadata{ + criteria := &slice.Criteria{ OperationIDs: o.operationIDs, Tags: o.tags, Paths: o.paths, } // Log what we're slicing - if len(metadata.OperationIDs) > 0 { - log.Printf("Slicing operations by IDs: %v", metadata.OperationIDs) + if len(criteria.OperationIDs) > 0 { + log.Printf("Slicing operations by IDs: %v", criteria.OperationIDs) } - if len(metadata.Tags) > 0 { - log.Printf("Slicing operations by tags: %v", metadata.Tags) + if len(criteria.Tags) > 0 { + log.Printf("Slicing operations by tags: %v", criteria.Tags) } - if len(metadata.Paths) > 0 { - log.Printf("Slicing operations by paths: %v", metadata.Paths) + if len(criteria.Paths) > 0 { + log.Printf("Slicing operations by paths: %v", criteria.Paths) } - // TODO: Add other filters - sliceFilter := filter.NewSliceFilter(specInfo.Spec, metadata) - if err := sliceFilter.Apply(); err != nil { + // Slice the spec (includes automatic cleanup of unused tags and schemas) + if err := slice.Slice(specInfo.Spec, criteria); err != nil { return err } diff --git a/tools/cli/internal/openapi/filter/filter.go b/tools/cli/internal/openapi/filter/filter.go index 5967f55f6d..40588555de 100644 --- a/tools/cli/internal/openapi/filter/filter.go +++ b/tools/cli/internal/openapi/filter/filter.go @@ -102,6 +102,14 @@ func FiltersToGetVersions(oas *openapi3.T, metadata *Metadata) []Filter { } } +func FiltersToCleanupRefs(oas *openapi3.T) []Filter { + return []Filter{ + &TagsFilter{oas: oas}, + &ParametersFilter{oas: oas}, + &SchemasFilter{oas: oas}, + } +} + func ApplyFilters(doc *openapi3.T, metadata *Metadata, filters func(oas *openapi3.T, metadata *Metadata) []Filter) (*openapi3.T, error) { if doc == nil { return nil, errors.New("openapi document is nil") diff --git a/tools/cli/internal/openapi/filter/parameters.go b/tools/cli/internal/openapi/filter/parameters.go new file mode 100644 index 0000000000..4b9f095da5 --- /dev/null +++ b/tools/cli/internal/openapi/filter/parameters.go @@ -0,0 +1,64 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filter + +import ( + "log" + "maps" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +type ParametersFilter struct { + oas *openapi3.T +} + +func (*ParametersFilter) ValidateMetadata() error { + return nil +} + +func (f *ParametersFilter) Apply() error { + if f.oas.Paths == nil { + return nil + } + + if f.oas.Components == nil || f.oas.Components.Parameters == nil { + return nil + } + + oasSpecAsBytes, err := f.oas.MarshalJSON() + if err != nil { + return err + } + + spec := string(oasSpecAsBytes) + parametersToDelete := make([]string, 0) + for k := range f.oas.Components.Parameters { + ref := "#/components/parameters/" + k + if !strings.Contains(spec, ref) { + parametersToDelete = append(parametersToDelete, k) + } + } + + for _, parameterToDelete := range parametersToDelete { + log.Printf("Deleting unused parameter: %q", parameterToDelete) + maps.DeleteFunc(f.oas.Components.Parameters, func(k string, _ *openapi3.ParameterRef) bool { + return k == parameterToDelete + }) + } + + return nil +} diff --git a/tools/cli/internal/openapi/filter/parameters_test.go b/tools/cli/internal/openapi/filter/parameters_test.go new file mode 100644 index 0000000000..4aa99a37b2 --- /dev/null +++ b/tools/cli/internal/openapi/filter/parameters_test.go @@ -0,0 +1,626 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filter + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/mongodb/openapi/tools/cli/internal/pointer" + "github.com/stretchr/testify/require" +) + +func TestParametersFilter_Apply(t *testing.T) { + testCases := []struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T + }{ + unusedParametersScenario(), + usedParametersInOperationScenario(), + usedParametersAtPathLevelScenario(), + mixedParametersScenario(), + onlyUsedParametersScenario(), + nilComponentsScenario(), + nilParametersScenario(), + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := &ParametersFilter{ + oas: tc.initSpec, + } + + require.NoError(t, f.Apply()) + require.Equal(t, tc.wantedSpec, f.oas) + }) + } +} + +func unusedParametersScenario() struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T +} { + return struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T + }{ + name: "Remove unused parameters", + initSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "UnusedParam1": { + Value: &openapi3.Parameter{ + Name: "unused1", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + "UnusedParam2": { + Value: &openapi3.Parameter{ + Name: "unused2", + In: "header", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }, + }, + }, + }, + }, + }, + }, + wantedSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{}, + }, + }, + } +} + +func usedParametersInOperationScenario() struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T +} { + return struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T + }{ + name: "Keep parameters used in operations", + initSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/PageNum", + }, + }, + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "PageNum": { + Value: &openapi3.Parameter{ + Name: "pageNum", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }, + }, + }, + }, + }, + }, + }, + wantedSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/PageNum", + }, + }, + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "PageNum": { + Value: &openapi3.Parameter{ + Name: "pageNum", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }, + }, + }, + }, + }, + }, + }, + } +} + +func usedParametersAtPathLevelScenario() struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T +} { + return struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T + }{ + name: "Keep parameters used at path level", + initSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test/{id}", &openapi3.PathItem{ + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/PathId", + }, + }, + Get: &openapi3.Operation{ + OperationID: "testOperation", + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "PathId": { + Value: &openapi3.Parameter{ + Name: "id", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + wantedSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test/{id}", &openapi3.PathItem{ + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/PathId", + }, + }, + Get: &openapi3.Operation{ + OperationID: "testOperation", + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "PathId": { + Value: &openapi3.Parameter{ + Name: "id", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + } +} + +func mixedParametersScenario() struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T +} { + return struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T + }{ + name: "Remove only unused parameters, keep used ones", + initSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/UsedParam", + }, + }, + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "UsedParam": { + Value: &openapi3.Parameter{ + Name: "used", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + "UnusedParam1": { + Value: &openapi3.Parameter{ + Name: "unused1", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + "UnusedParam2": { + Value: &openapi3.Parameter{ + Name: "unused2", + In: "header", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }, + }, + }, + }, + }, + }, + }, + wantedSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/UsedParam", + }, + }, + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "UsedParam": { + Value: &openapi3.Parameter{ + Name: "used", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + } +} + +func onlyUsedParametersScenario() struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T +} { + return struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T + }{ + name: "Do not remove any parameters when all are used", + initSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths( + openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "getTest", + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/Param1", + }, + }, + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + Post: &openapi3.Operation{ + OperationID: "postTest", + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/Param2", + }, + }, + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + }), + ), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "Param1": { + Value: &openapi3.Parameter{ + Name: "param1", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + "Param2": { + Value: &openapi3.Parameter{ + Name: "param2", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }, + }, + }, + }, + }, + }, + }, + wantedSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths( + openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "getTest", + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/Param1", + }, + }, + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + Post: &openapi3.Operation{ + OperationID: "postTest", + Parameters: openapi3.Parameters{ + { + Ref: "#/components/parameters/Param2", + }, + }, + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + }), + ), + Components: &openapi3.Components{ + Parameters: map[string]*openapi3.ParameterRef{ + "Param1": { + Value: &openapi3.Parameter{ + Name: "param1", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + "Param2": { + Value: &openapi3.Parameter{ + Name: "param2", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }, + }, + }, + }, + }, + }, + }, + } +} + +func nilComponentsScenario() struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T +} { + return struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T + }{ + name: "Handle nil components gracefully", + initSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: nil, + }, + wantedSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: nil, + }, + } +} + +func nilParametersScenario() struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T +} { + return struct { + name string + initSpec *openapi3.T + wantedSpec *openapi3.T + }{ + name: "Handle nil parameters map gracefully", + initSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: nil, + }, + }, + wantedSpec: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Version: "1.0", + }, + Paths: openapi3.NewPaths(openapi3.WithPath("/test", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "testOperation", + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Description: pointer.Get("Success"), + })), + }, + })), + Components: &openapi3.Components{ + Parameters: nil, + }, + }, + } +} diff --git a/tools/cli/internal/openapi/filter/schemas.go b/tools/cli/internal/openapi/filter/schemas.go index 6b0f775fa0..c70c63e2fc 100644 --- a/tools/cli/internal/openapi/filter/schemas.go +++ b/tools/cli/internal/openapi/filter/schemas.go @@ -36,6 +36,10 @@ func (f *SchemasFilter) Apply() error { return nil } + if f.oas.Components == nil || f.oas.Components.Schemas == nil { + return nil + } + for { oasSpecAsBytes, err := f.oas.MarshalJSON() if err != nil { diff --git a/tools/cli/internal/openapi/filter/slice.go b/tools/cli/internal/openapi/filter/slice.go deleted file mode 100644 index 542c6b9068..0000000000 --- a/tools/cli/internal/openapi/filter/slice.go +++ /dev/null @@ -1,90 +0,0 @@ -package filter - -import ( - "errors" - "regexp" - "slices" - - "github.com/getkin/kin-openapi/openapi3" -) - -type SliceMetadata struct { - OperationIDs []string - Tags []string - Paths []string -} - -type SliceFilter struct { - oas *openapi3.T - metadata *SliceMetadata -} - -func NewSliceFilter(oas *openapi3.T, metadata *SliceMetadata) *SliceFilter { - return &SliceFilter{ - oas: oas, - metadata: metadata, - } -} - -func (*SliceFilter) ValidateMetadata() error { - return nil -} - -func (f *SliceFilter) Apply() error { - if f.oas == nil { - return errors.New("OpenAPI spec is nil") - } - - if f.oas.Paths == nil { - return nil - } - - for path, pathItem := range f.oas.Paths.Map() { - if pathItem == nil { - continue - } - hasOperations := false - for method, operation := range pathItem.Operations() { - if operation == nil { - continue - } - if f.matches(path, operation) { - hasOperations = true - } else { - pathItem.SetOperation(method, nil) - } - } - if !hasOperations { - f.oas.Paths.Delete(path) - } - } - return nil -} - -func (f *SliceFilter) matches(path string, operation *openapi3.Operation) bool { - normalizedPath := normalizePath(path) - for _, pattern := range f.metadata.Paths { - if normalizedPath == normalizePath(pattern) { - return true - } - } - - // Check if the operation ID matches - if slices.Contains(f.metadata.OperationIDs, operation.OperationID) { - return true - } - - // Check if the tag matches - for _, tag := range f.metadata.Tags { - if slices.Contains(operation.Tags, tag) { - return true - } - } - return false -} - -var pathParamRegex = regexp.MustCompile(`\{[^}]+\}`) - -func normalizePath(path string) string { - return pathParamRegex.ReplaceAllString(path, "{}") -} diff --git a/tools/cli/internal/openapi/slice/slice.go b/tools/cli/internal/openapi/slice/slice.go new file mode 100644 index 0000000000..99056473d4 --- /dev/null +++ b/tools/cli/internal/openapi/slice/slice.go @@ -0,0 +1,108 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slice + +import ( + "errors" + "regexp" + "slices" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/mongodb/openapi/tools/cli/internal/openapi/filter" +) + +// Criteria defines the selection criteria for slicing an OpenAPI spec. +// Operations matching ANY of the specified criteria will be included (OR logic). +type Criteria struct { + OperationIDs []string // Match by operation ID + Tags []string // Match by tag + Paths []string // Match by path (supports parameter normalization) +} + +// Slice creates a minispec containing only operations matching the criteria. +// It removes non-matching operations and automatically cleans up unused tags and schemas. +// This ensures the resulting spec is valid and contains no dangling references. +func Slice(spec *openapi3.T, criteria *Criteria) error { + if spec == nil { + return errors.New("OpenAPI spec is nil") + } + + if spec.Paths == nil { + return nil + } + + for path, pathItem := range spec.Paths.Map() { + if pathItem == nil { + continue + } + hasOperations := false + for method, operation := range pathItem.Operations() { + if operation == nil { + continue + } + if matches(path, operation, criteria) { + hasOperations = true + } else { + pathItem.SetOperation(method, nil) + } + } + if !hasOperations { + spec.Paths.Delete(path) + } + } + + filters := filter.FiltersToCleanupRefs(spec) + for _, f := range filters { + if err := f.Apply(); err != nil { + return err + } + } + + return nil +} + +// matches checks if an operation matches any of the criteria (OR logic). +func matches(path string, operation *openapi3.Operation, criteria *Criteria) bool { + // Check if the path matches (with normalization) + normalizedPath := normalizePath(path) + for _, pattern := range criteria.Paths { + if normalizedPath == normalizePath(pattern) { + return true + } + } + + // Check if the operation ID matches + if slices.Contains(criteria.OperationIDs, operation.OperationID) { + return true + } + + // Check if any tag matches + for _, tag := range criteria.Tags { + if slices.Contains(operation.Tags, tag) { + return true + } + } + + return false +} + +var pathParamRegex = regexp.MustCompile(`\{[^}]+\}`) + +// normalizePath replaces all path parameters with {} for comparison. +// This allows matching paths with different parameter names. +// Example: /api/v2/groups/{groupId} and /api/v2/groups/{id} both normalize to /api/v2/groups/{}. +func normalizePath(path string) string { + return pathParamRegex.ReplaceAllString(path, "{}") +} diff --git a/tools/cli/internal/openapi/filter/slice_test.go b/tools/cli/internal/openapi/slice/slice_test.go similarity index 86% rename from tools/cli/internal/openapi/filter/slice_test.go rename to tools/cli/internal/openapi/slice/slice_test.go index f1c75c55ef..123c103c5c 100644 --- a/tools/cli/internal/openapi/filter/slice_test.go +++ b/tools/cli/internal/openapi/slice/slice_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package filter +package slice import ( "testing" @@ -58,7 +58,7 @@ func TestNormalizePath(t *testing.T) { } } -func TestSliceFilter_Apply_FilterByPath(t *testing.T) { +func TestSlice_FilterByPath(t *testing.T) { tests := []struct { name string paths []string @@ -141,11 +141,9 @@ func TestSliceFilter_Apply_FilterByPath(t *testing.T) { oas.Paths.Set(path, pathItem) } - filter := NewSliceFilter(oas, &SliceMetadata{ + err := Slice(oas, &Criteria{ Paths: tt.paths, }) - - err := filter.Apply() require.NoError(t, err) assert.Equal(t, len(tt.expectedPaths), oas.Paths.Len()) @@ -156,7 +154,7 @@ func TestSliceFilter_Apply_FilterByPath(t *testing.T) { } } -func TestSliceFilter_Apply_FilterByOperationID(t *testing.T) { +func TestSlice_FilterByOperationID(t *testing.T) { oas := &openapi3.T{ Paths: openapi3.NewPaths(), } @@ -170,11 +168,9 @@ func TestSliceFilter_Apply_FilterByOperationID(t *testing.T) { Delete: &openapi3.Operation{OperationID: "deleteUser"}, }) - filter := NewSliceFilter(oas, &SliceMetadata{ + err := Slice(oas, &Criteria{ OperationIDs: []string{"getGroup", "deleteUser"}, }) - - err := filter.Apply() require.NoError(t, err) // Should keep both paths @@ -193,7 +189,7 @@ func TestSliceFilter_Apply_FilterByOperationID(t *testing.T) { assert.NotNil(t, usersPath.Delete) } -func TestSliceFilter_Apply_FilterByTag(t *testing.T) { +func TestSlice_FilterByTag(t *testing.T) { oas := &openapi3.T{ Paths: openapi3.NewPaths(), } @@ -210,11 +206,9 @@ func TestSliceFilter_Apply_FilterByTag(t *testing.T) { Get: &openapi3.Operation{OperationID: "getCluster", Tags: []string{"Clusters"}}, }) - filter := NewSliceFilter(oas, &SliceMetadata{ + err := Slice(oas, &Criteria{ Tags: []string{"Groups", "Clusters"}, }) - - err := filter.Apply() require.NoError(t, err) // Should keep groups and clusters paths, remove users @@ -224,7 +218,7 @@ func TestSliceFilter_Apply_FilterByTag(t *testing.T) { assert.Nil(t, oas.Paths.Value("/api/v2/users/{userId}")) } -func TestSliceFilter_Apply_MultipleCriteria(t *testing.T) { +func TestSlice_MultipleCriteria(t *testing.T) { oas := &openapi3.T{ Paths: openapi3.NewPaths(), } @@ -242,13 +236,11 @@ func TestSliceFilter_Apply_MultipleCriteria(t *testing.T) { }) // Match by: path (/api/v2/groups/{id}), operationID (getUser), or tag (Clusters) - filter := NewSliceFilter(oas, &SliceMetadata{ + err := Slice(oas, &Criteria{ Paths: []string{"/api/v2/groups/{id}"}, OperationIDs: []string{"getUser"}, Tags: []string{"Clusters"}, }) - - err := filter.Apply() require.NoError(t, err) // Should keep all three paths @@ -272,7 +264,7 @@ func TestSliceFilter_Apply_MultipleCriteria(t *testing.T) { assert.NotNil(t, clustersPath.Get) } -func TestSliceFilter_Apply_EmptyMetadata(t *testing.T) { +func TestSlice_EmptyCriteria(t *testing.T) { oas := &openapi3.T{ Paths: openapi3.NewPaths(), } @@ -281,39 +273,33 @@ func TestSliceFilter_Apply_EmptyMetadata(t *testing.T) { Get: &openapi3.Operation{OperationID: "getGroup"}, }) - filter := NewSliceFilter(oas, &SliceMetadata{}) - - err := filter.Apply() + err := Slice(oas, &Criteria{}) require.NoError(t, err) - // With empty metadata, nothing matches, so all paths should be removed + // With empty criteria, nothing matches, so all paths should be removed assert.Equal(t, 0, oas.Paths.Len()) } -func TestSliceFilter_Apply_NilOAS(t *testing.T) { - filter := NewSliceFilter(nil, &SliceMetadata{ +func TestSlice_NilSpec(t *testing.T) { + err := Slice(nil, &Criteria{ Paths: []string{"/api/v2/groups/{id}"}, }) - - err := filter.Apply() require.Error(t, err) assert.Contains(t, err.Error(), "OpenAPI spec is nil") } -func TestSliceFilter_Apply_NilPaths(t *testing.T) { +func TestSlice_NilPaths(t *testing.T) { oas := &openapi3.T{ Paths: nil, } - filter := NewSliceFilter(oas, &SliceMetadata{ + err := Slice(oas, &Criteria{ Paths: []string{"/api/v2/groups/{id}"}, }) - - err := filter.Apply() require.NoError(t, err) // Should not error, just return early } -func TestSliceFilter_Apply_RemovePathWithNoMatchingOperations(t *testing.T) { +func TestSlice_RemovePathWithNoMatchingOperations(t *testing.T) { oas := &openapi3.T{ Paths: openapi3.NewPaths(), } @@ -324,19 +310,11 @@ func TestSliceFilter_Apply_RemovePathWithNoMatchingOperations(t *testing.T) { }) // Filter by tag that doesn't match any operations - filter := NewSliceFilter(oas, &SliceMetadata{ + err := Slice(oas, &Criteria{ Tags: []string{"Users"}, }) - - err := filter.Apply() require.NoError(t, err) // Path should be removed since no operations match assert.Equal(t, 0, oas.Paths.Len()) } - -func TestSliceFilter_ValidateMetadata(t *testing.T) { - filter := NewSliceFilter(&openapi3.T{}, &SliceMetadata{}) - err := filter.ValidateMetadata() - require.NoError(t, err) // Should always return nil -} From 43e2294bb1fdb45d8d764552c1e572ea173e0bc0 Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Fri, 19 Dec 2025 15:00:28 +0200 Subject: [PATCH 6/6] fix: add flag and usage for the slice parameters --- tools/cli/internal/cli/flag/flag.go | 3 +++ tools/cli/internal/cli/slice/slice.go | 6 +++--- tools/cli/internal/cli/split/split.go | 2 +- tools/cli/internal/cli/split/split_test.go | 2 +- tools/cli/internal/cli/usage/usage.go | 3 +++ tools/cli/internal/openapi/filter/filter.go | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tools/cli/internal/cli/flag/flag.go b/tools/cli/internal/cli/flag/flag.go index 78e3057dbd..421fd47019 100644 --- a/tools/cli/internal/cli/flag/flag.go +++ b/tools/cli/internal/cli/flag/flag.go @@ -51,4 +51,7 @@ const ( StabilityLevelShort = "l" Version = "version" VersionShort = "v" + OperationIDs = "ids" + Tags = "tags" + Paths = "paths" ) diff --git a/tools/cli/internal/cli/slice/slice.go b/tools/cli/internal/cli/slice/slice.go index 0ca6fdb05c..f81aa33e48 100644 --- a/tools/cli/internal/cli/slice/slice.go +++ b/tools/cli/internal/cli/slice/slice.go @@ -129,9 +129,9 @@ Multiple values can be specified as comma-separated lists.`, cmd.Flags().StringVarP(&opts.basePath, flag.Spec, flag.SpecShort, "-", usage.Spec) cmd.Flags().StringVarP(&opts.outputPath, flag.Output, flag.OutputShort, "", usage.Output) cmd.Flags().StringVarP(&opts.format, flag.Format, flag.FormatShort, openapi.ALL, usage.Format) - cmd.Flags().StringSliceVar(&opts.operationIDs, "operation-ids", []string{}, "Comma-separated list of operation IDs to extract") - cmd.Flags().StringSliceVar(&opts.tags, "tags", []string{}, "Comma-separated list of tags to extract") - cmd.Flags().StringSliceVar(&opts.paths, "paths", []string{}, "Comma-separated list of path patterns to extract") + cmd.Flags().StringSliceVar(&opts.operationIDs, flag.OperationIDs, []string{}, usage.OperationIDs) + cmd.Flags().StringSliceVar(&opts.tags, flag.Tags, []string{}, usage.Tags) + cmd.Flags().StringSliceVar(&opts.paths, flag.Paths, []string{}, usage.Paths) // Required flags _ = cmd.MarkFlagRequired(flag.Output) diff --git a/tools/cli/internal/cli/split/split.go b/tools/cli/internal/cli/split/split.go index bdb836b4be..63407da6c9 100644 --- a/tools/cli/internal/cli/split/split.go +++ b/tools/cli/internal/cli/split/split.go @@ -1,4 +1,4 @@ -// Copyright 2024 MongoDB Inc +// Copyright 2025 MongoDB Inc // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tools/cli/internal/cli/split/split_test.go b/tools/cli/internal/cli/split/split_test.go index 983b71e095..96b019c92d 100644 --- a/tools/cli/internal/cli/split/split_test.go +++ b/tools/cli/internal/cli/split/split_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 MongoDB Inc +// Copyright 2025 MongoDB Inc // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tools/cli/internal/cli/usage/usage.go b/tools/cli/internal/cli/usage/usage.go index 043e5ad2a1..a05c90b6f4 100644 --- a/tools/cli/internal/cli/usage/usage.go +++ b/tools/cli/internal/cli/usage/usage.go @@ -39,4 +39,7 @@ const ( To = "Date in the format YYYY-MM-DD that indicates the end of a date range" StabilityLevel = "Stability level related to the API Version. Valid values: [STABLE, UPCOMING, PUBLIC-PREVIEW, PRIVATE-PREVIEW]" Version = "Version of the API." + Tags = "Comma-separated list of tags to extract." + OperationIDs = "Comma-separated list of operation IDs to extract." + Paths = "Comma-separated list of path patterns to extract." ) diff --git a/tools/cli/internal/openapi/filter/filter.go b/tools/cli/internal/openapi/filter/filter.go index 40588555de..3d33141b27 100644 --- a/tools/cli/internal/openapi/filter/filter.go +++ b/tools/cli/internal/openapi/filter/filter.go @@ -105,8 +105,8 @@ func FiltersToGetVersions(oas *openapi3.T, metadata *Metadata) []Filter { func FiltersToCleanupRefs(oas *openapi3.T) []Filter { return []Filter{ &TagsFilter{oas: oas}, - &ParametersFilter{oas: oas}, &SchemasFilter{oas: oas}, + &ParametersFilter{oas: oas}, } }