diff --git a/openapi/cmd/README.md b/openapi/cmd/README.md index 98cabf7..1dd5c37 100644 --- a/openapi/cmd/README.md +++ b/openapi/cmd/README.md @@ -251,10 +251,17 @@ Create a YAML configuration file to control sanitization behavior: ```yaml # sanitize-config.yaml -# Only remove extensions that match these patterns, null will remove ALL extensions, [] will remove no extensions (default: null, removes ALL extensions) +# Extension filtering (not provided or empty = remove all extensions by default) extensionPatterns: - - "x-go-*" - - "x-internal-*" + # Whitelist: keep only matching extensions (when provided, only these are kept) + # Keep takes precedence over Remove when both are specified + keep: + - "x-speakeasy-schema-*" # Example: keep only schema-related extensions + # Blacklist: remove only matching extensions, keep all others + remove: + - "x-go-*" + - "x-internal-*" + - "x-speakeasy-*" # Combined with Keep above, removes all x-speakeasy-* EXCEPT x-speakeasy-schema-* # Keep unused components (default: false, removes them) keepUnusedComponents: true @@ -333,7 +340,7 @@ components: **After sanitization (with pattern config):** -Using config with `extensionPatterns: ["x-go-*"]`: +Using config with `extensionPatterns: { remove: ["x-go-*"] }`: ```yaml openapi: 3.1.0 diff --git a/openapi/cmd/sanitize.go b/openapi/cmd/sanitize.go index 6777e87..ce0f582 100644 --- a/openapi/cmd/sanitize.go +++ b/openapi/cmd/sanitize.go @@ -53,10 +53,17 @@ Benefits of sanitization: Configuration file format (YAML): - # Only remove extensions that match these patterns, null will remove ALL extensions, [] will remove no extensions (default: null, removes ALL extensions) + # Extension filtering (not provided or empty = remove all extensions by default) extensionPatterns: - - "x-go-*" - - "x-internal-*" + # Keep only matching extensions (when provided, only these are kept) + # Keep takes precedence over Remove when both are specified + keep: + - "x-speakeasy-schema-*" # Keeps x-speakeasy-schema-*, allows narrowing Remove patterns + # Remove only matching extensions, keep all others + remove: + - "x-go-*" + - "x-internal-*" + - "x-speakeasy-*" # When combined with Keep above, removes all x-speakeasy-* EXCEPT x-speakeasy-schema-* # Keep unused components (default: false, removes them) keepUnusedComponents: true @@ -164,16 +171,28 @@ func reportSanitizationResults(processor *OpenAPIProcessor, opts *openapi.Saniti var messages []string // Determine what was done with extensions - switch { - case opts == nil || opts.ExtensionPatterns == nil: + if opts == nil || opts.ExtensionPatterns == nil { // nil patterns = remove all extensions (default) messages = append(messages, "removed all extensions") - case len(opts.ExtensionPatterns) == 0: - // empty slice = keep all extensions (explicit) - messages = append(messages, "kept all extensions") - default: - // specific patterns = remove matching extensions - messages = append(messages, fmt.Sprintf("removed extensions matching %v", opts.ExtensionPatterns)) + } else { + filter := opts.ExtensionPatterns + hasKeep := len(filter.Keep) > 0 + hasRemove := len(filter.Remove) > 0 + + switch { + case !hasKeep && !hasRemove: + // empty filter = remove all extensions + messages = append(messages, "removed all extensions") + case hasKeep && !hasRemove: + // keep only + messages = append(messages, fmt.Sprintf("kept only extensions matching %v", filter.Keep)) + case !hasKeep && hasRemove: + // remove only + messages = append(messages, fmt.Sprintf("removed extensions matching %v", filter.Remove)) + case hasKeep && hasRemove: + // both (keep overrides remove) + messages = append(messages, fmt.Sprintf("kept extensions matching %v (remove patterns %v overridden by keep)", filter.Keep, filter.Remove)) + } } // Determine what was done with components diff --git a/openapi/sanitize.go b/openapi/sanitize.go index 6aecf9b..bf37826 100644 --- a/openapi/sanitize.go +++ b/openapi/sanitize.go @@ -13,15 +13,77 @@ import ( "gopkg.in/yaml.v3" ) +// ExtensionFilter specifies patterns for filtering extensions using allowed and denied lists. +type ExtensionFilter struct { + // Keep specifies glob patterns for extensions to keep (allow list). + // If provided and not empty, ONLY extensions matching these patterns are kept. + // When both Keep and Remove are provided, extensions matching Keep are kept even if they also match Remove. + // Use ["*"] to keep all extensions. + // nil or []: Defaults to deny list mode using Remove patterns, or removes all if Remove is also empty. + Keep []string `yaml:"keep,omitempty"` + + // Remove specifies glob patterns for extensions to remove (deny list). + // Extensions matching these patterns are removed. + // When both Keep and Remove are provided, Keep takes precedence (allow overrides deny). + // nil or []: If Keep is also empty, removes all extensions. + Remove []string `yaml:"remove,omitempty"` +} + // SanitizeOptions configures the sanitization behavior. // Can be loaded from a YAML config file or constructed programmatically. // Zero values provide aggressive cleanup (remove everything non-standard). type SanitizeOptions struct { - // ExtensionPatterns specifies glob patterns for selective extension removal. - // nil: Remove ALL extensions (default) - // []: Keep ALL extensions (empty array) - // ["x-go-*", ...]: Remove only extensions matching these patterns - ExtensionPatterns []string `yaml:"extensionPatterns"` + // ExtensionPatterns specifies patterns for selective extension filtering. + // Supports whitelist (Keep), blacklist (Remove), or both. + // + // Default behavior: + // nil: Remove ALL extensions (default, aggressive cleanup) + // &ExtensionFilter{}: Remove ALL extensions (empty whitelist and blacklist) + // + // Whitelist mode (Keep provided, Remove empty): + // When Keep is provided and not empty, ONLY extensions matching Keep patterns are kept. + // + // &ExtensionFilter{Keep: ["x-speakeasy-*"]}: Keep only x-speakeasy-*, remove all others + // &ExtensionFilter{Keep: ["*"]}: Keep ALL extensions (wildcard matches everything) + // + // Blacklist mode (Remove provided, Keep empty): + // When Keep is empty/nil and Remove is provided, only extensions matching Remove are removed. + // All other extensions are kept. + // + // &ExtensionFilter{Remove: ["x-go-*"]}: Remove only x-go-*, keep all others + // &ExtensionFilter{Remove: ["x-go-*", "x-internal-*"]}: Remove x-go-* and x-internal-*, keep others + // + // Combined mode (both Keep and Remove provided): + // When both are provided, Keep takes precedence (whitelist overrides blacklist). + // Extensions matching Keep are kept even if they also match Remove. + // + // &ExtensionFilter{Keep: ["x-speakeasy-schema*"], Remove: ["x-speakeasy-*"]}: + // Remove all x-speakeasy-{something} extensions except x-speakeasy-schema-{something} (whitelist overrides wildcard blacklist) + // + // Examples: + // // Remove all extensions (default) + // opts := &SanitizeOptions{ExtensionPatterns: nil} + // + // // Remove all extensions (explicit) + // opts := &SanitizeOptions{ExtensionPatterns: &ExtensionFilter{}} + // + // // Blacklist: Remove only x-go-* extensions + // opts := &SanitizeOptions{ExtensionPatterns: &ExtensionFilter{Remove: []string{"x-go-*"}}} + // + // // Whitelist: Keep only x-speakeasy-* extensions + // opts := &SanitizeOptions{ExtensionPatterns: &ExtensionFilter{Keep: []string{"x-speakeasy-*"}}} + // + // // Whitelist: Keep all extensions + // opts := &SanitizeOptions{ExtensionPatterns: &ExtensionFilter{Keep: []string{"*"}}} + // + // // Combined: Remove all except x-speakeasy-* (whitelist narrows broad blacklist) + // opts := &SanitizeOptions{ + // ExtensionPatterns: &ExtensionFilter{ + // Keep: []string{"x-speakeasy-schema-*"}, + // Remove: []string{"x-speakeasy-*"}, // Remove all x-speakeasy-* extensions except those matching x-speakeasy-schema-* + // }, + // } + ExtensionPatterns *ExtensionFilter `yaml:"extensionPatterns,omitempty"` // KeepUnusedComponents preserves unused components in the document. // Default (false): removes unused components. @@ -65,8 +127,9 @@ type SanitizeResult struct { // - Unknown properties not defined in the OpenAPI specification // // Extension removal behavior: -// - If opts is nil or opts.ExtensionPatterns is empty: removes ALL x-* extensions -// - If opts.ExtensionPatterns has values: removes only matching extensions +// - If opts is nil or opts.ExtensionPatterns is nil: removes ALL x-* extensions (default) +// - If opts.ExtensionPatterns is &ExtensionFilter{}: removes ALL extensions (empty filter) +// - Use Keep patterns for whitelist mode, Remove patterns for blacklist mode // // Example usage: // @@ -79,9 +142,9 @@ type SanitizeResult struct { // fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) // } // -// // Remove only x-go-* extensions, keep everything else +// // Blacklist: Remove only x-go-* extensions, keep everything else // opts := &SanitizeOptions{ -// ExtensionPatterns: []string{"x-go-*"}, +// ExtensionPatterns: &ExtensionFilter{Remove: []string{"x-go-*"}}, // KeepUnusedComponents: true, // KeepUnknownProperties: true, // } @@ -90,9 +153,9 @@ type SanitizeResult struct { // return fmt.Errorf("failed to sanitize document: %w", err) // } // -// // Remove extensions and unknown properties, but keep components +// // Whitelist: Keep only x-speakeasy-* extensions, remove all others // opts := &SanitizeOptions{ -// KeepUnusedComponents: true, +// ExtensionPatterns: &ExtensionFilter{Keep: []string{"x-speakeasy-*"}}, // } // result, err := Sanitize(ctx, doc, opts) // @@ -167,37 +230,182 @@ func LoadSanitizeConfigFromFile(path string) (*SanitizeOptions, error) { } // removeExtensions walks through the document and removes extensions based on options. +// determineRemovalAction determines whether an extension should be removed based on filtering rules. +func determineRemovalAction( + key string, + removeAll bool, + hasWhitelist bool, + keepPatterns []string, + removePatterns []string, + keepPatternUsage map[string]*matchInfo, + removePatternUsage map[string]*matchInfo, +) bool { + switch { + case removeAll: + // Default: remove all extensions + return true + + case hasWhitelist && len(removePatterns) == 0: + // Pure whitelist mode: remove unless it matches Keep patterns + shouldRemove := true // default to remove + + // Check if extension matches any Keep pattern + for _, pattern := range keepPatterns { + info := keepPatternUsage[pattern] + if info == nil { + continue + } + matched, err := filepath.Match(pattern, key) + if err != nil { + info.invalid = true + continue + } + if matched { + info.matched = true + shouldRemove = false // keep it + break + } + } + return shouldRemove + + case hasWhitelist && len(removePatterns) > 0: + // Combined mode: Apply Remove patterns, but Keep overrides + // First check if it matches Remove patterns + matchesRemove := false + for _, pattern := range removePatterns { + info := removePatternUsage[pattern] + if info == nil { + continue + } + matched, err := filepath.Match(pattern, key) + if err != nil { + info.invalid = true + continue + } + if matched { + matchesRemove = true + break + } + } + + if matchesRemove { + // It matches Remove, but check if Keep overrides + shouldRemove := true // default to remove + for _, pattern := range keepPatterns { + info := keepPatternUsage[pattern] + if info == nil { + continue + } + matched, err := filepath.Match(pattern, key) + if err != nil { + info.invalid = true + continue + } + if matched { + info.matched = true + shouldRemove = false // keep it (whitelist overrides blacklist) + break + } + } + + // Track that Remove pattern matched (even if Keep overrode it) + if matchesRemove { + for _, pattern := range removePatterns { + matched, err := filepath.Match(pattern, key) + if err == nil && matched { + if info := removePatternUsage[pattern]; info != nil { + info.matched = true + } + } + } + } + return shouldRemove + } + // If it doesn't match Remove patterns, keep it (not affected) + return false + + case len(removePatterns) > 0: + // Pure blacklist mode: keep unless it matches Remove patterns + shouldRemove := false // default to keep + + // Check if extension matches any Remove pattern + for _, pattern := range removePatterns { + info := removePatternUsage[pattern] + if info == nil { + continue + } + matched, err := filepath.Match(pattern, key) + if err != nil { + info.invalid = true + continue + } + if matched { + info.matched = true + shouldRemove = true // remove it (matches blacklist) + break + } + } + return shouldRemove + + default: + return false + } +} + +// matchInfo tracks pattern usage and validity for warning generation. +type matchInfo struct { + invalid bool // true if pattern has invalid syntax + matched bool // true if pattern matched at least one extension +} + // Returns a slice of warnings for invalid patterns or patterns that matched nothing. func removeExtensions(ctx context.Context, doc *OpenAPI, opts *SanitizeOptions) ([]string, error) { - // Determine removal strategy: - // - nil ExtensionPatterns: remove ALL extensions (default) - // - empty array []: keep ALL extensions (explicit no-op) - // - non-empty array: remove only matching patterns + // Determine removal strategy based on ExtensionPatterns: + // - nil: remove ALL extensions (default) + // - &ExtensionFilter{}: remove ALL extensions (empty whitelist and blacklist) + // - &ExtensionFilter{Keep: [...}}: whitelist mode - keep only matching extensions + // - &ExtensionFilter{Remove: [...}}: blacklist mode - remove only matching extensions + // - &ExtensionFilter{Keep: [...], Remove: [...}}: whitelist overrides blacklist - var patterns []string - removeAll := true + var keepPatterns, removePatterns []string + hasWhitelist := false + removeAll := true // Default: remove all extensions - // Handle extension patterns if explicitly set if opts != nil && opts.ExtensionPatterns != nil { - if len(opts.ExtensionPatterns) == 0 { - // Empty array explicitly set = keep all extensions - return nil, nil + filter := opts.ExtensionPatterns + + // Check for whitelist (Keep patterns) + if len(filter.Keep) > 0 { + keepPatterns = filter.Keep + hasWhitelist = true + removeAll = false + } + + // Check for blacklist (Remove patterns) + if len(filter.Remove) > 0 { + removePatterns = filter.Remove + if !hasWhitelist { + // Blacklist mode only (no whitelist) + removeAll = false + } } - // Use patterns for selective removal - patterns = opts.ExtensionPatterns - removeAll = false - } - // Track pattern usage: map[pattern]MatchInfo - type matchInfo struct { - invalid bool // true if pattern has invalid syntax - matched bool // true if pattern matched at least one extension + // If both Keep and Remove are empty, remove all (empty filter) + if len(filter.Keep) == 0 && len(filter.Remove) == 0 { + removeAll = true + } } - patternUsage := make(map[string]*matchInfo) + + // Track pattern usage for warnings + keepPatternUsage := make(map[string]*matchInfo) + removePatternUsage := make(map[string]*matchInfo) // Initialize tracking for all patterns - for _, pattern := range patterns { - patternUsage[pattern] = &matchInfo{} + for _, pattern := range keepPatterns { + keepPatternUsage[pattern] = &matchInfo{} + } + for _, pattern := range removePatterns { + removePatternUsage[pattern] = &matchInfo{} } // Walk through the document and process all Extensions @@ -211,27 +419,15 @@ func removeExtensions(ctx context.Context, doc *OpenAPI, opts *SanitizeOptions) // Collect keys to remove keysToRemove := []string{} for key := range ext.All() { - var shouldRemove bool - if removeAll { - // Remove all extensions - shouldRemove = true - } else { - // Check if extension matches any pattern - for _, pattern := range patterns { - info := patternUsage[pattern] - matched, err := filepath.Match(pattern, key) - if err != nil { - // Mark pattern as invalid - info.invalid = true - continue - } - if matched { - // Mark pattern as having matched something - info.matched = true - shouldRemove = true - } - } - } + shouldRemove := determineRemovalAction( + key, + removeAll, + hasWhitelist, + keepPatterns, + removePatterns, + keepPatternUsage, + removePatternUsage, + ) if shouldRemove { keysToRemove = append(keysToRemove, key) @@ -253,15 +449,30 @@ func removeExtensions(ctx context.Context, doc *OpenAPI, opts *SanitizeOptions) // Generate warnings for invalid patterns and patterns that never matched var warnings []string - for _, pattern := range patterns { - info := patternUsage[pattern] + + // Check Keep patterns + for _, pattern := range keepPatterns { + info := keepPatternUsage[pattern] + if info == nil { + continue + } + if info.invalid { + warnings = append(warnings, fmt.Sprintf("invalid keep pattern '%s' was skipped", pattern)) + } else if !info.matched { + warnings = append(warnings, fmt.Sprintf("keep pattern '%s' did not match any extensions in the document", pattern)) + } + } + + // Check Remove patterns + for _, pattern := range removePatterns { + info := removePatternUsage[pattern] if info == nil { continue } if info.invalid { - warnings = append(warnings, fmt.Sprintf("invalid glob pattern '%s' was skipped", pattern)) + warnings = append(warnings, fmt.Sprintf("invalid remove pattern '%s' was skipped", pattern)) } else if !info.matched { - warnings = append(warnings, fmt.Sprintf("pattern '%s' did not match any extensions in the document", pattern)) + warnings = append(warnings, fmt.Sprintf("remove pattern '%s' did not match any extensions in the document", pattern)) } } diff --git a/openapi/sanitize_test.go b/openapi/sanitize_test.go index 7e98983..22171fb 100644 --- a/openapi/sanitize_test.go +++ b/openapi/sanitize_test.go @@ -58,9 +58,11 @@ func TestSanitize_PatternBased_Success(t *testing.T) { require.NoError(t, err) require.Empty(t, validationErrs, "Input document should be valid") - // Sanitize with pattern matching - only remove x-go-* extensions + // Sanitize with pattern matching - only remove x-go-* extensions (blacklist mode) opts := &openapi.SanitizeOptions{ - ExtensionPatterns: []string{"x-go-*"}, + ExtensionPatterns: &openapi.ExtensionFilter{ + Remove: []string{"x-go-*"}, + }, KeepUnusedComponents: true, KeepUnknownProperties: true, } @@ -96,9 +98,11 @@ func TestSanitize_MultiplePatterns_Success(t *testing.T) { require.NoError(t, err) require.Empty(t, validationErrs, "Input document should be valid") - // Sanitize with multiple patterns + // Sanitize with multiple patterns (blacklist mode) opts := &openapi.SanitizeOptions{ - ExtensionPatterns: []string{"x-go-*", "x-internal-*"}, + ExtensionPatterns: &openapi.ExtensionFilter{ + Remove: []string{"x-go-*", "x-internal-*"}, + }, KeepUnusedComponents: true, } result, err := openapi.Sanitize(ctx, inputDoc, opts) @@ -206,8 +210,9 @@ func TestLoadSanitizeConfig_Success(t *testing.T) { t.Parallel() configYAML := `extensionPatterns: - - "x-go-*" - - "x-internal-*" + remove: + - "x-go-*" + - "x-internal-*" keepUnusedComponents: true keepUnknownProperties: false ` @@ -218,7 +223,8 @@ keepUnknownProperties: false require.NotNil(t, opts) // Verify config was loaded correctly - assert.Equal(t, []string{"x-go-*", "x-internal-*"}, opts.ExtensionPatterns) + require.NotNil(t, opts.ExtensionPatterns) + assert.Equal(t, []string{"x-go-*", "x-internal-*"}, opts.ExtensionPatterns.Remove) assert.True(t, opts.KeepUnusedComponents) assert.False(t, opts.KeepUnknownProperties) } @@ -236,7 +242,8 @@ func TestLoadSanitizeConfig_InvalidYAML_Error(t *testing.T) { t.Parallel() invalidYAML := `extensionPatterns: - - "x-go-*" + remove: + - "x-go-*" invalid yaml syntax here: [ keepUnusedComponents: true ` @@ -262,7 +269,8 @@ func TestSanitize_ConfigFile_Success(t *testing.T) { require.Empty(t, validationErrs, "Input document should be valid") configYAML := `extensionPatterns: - - "x-go-*" + remove: + - "x-go-*" keepUnusedComponents: true keepUnknownProperties: true ` @@ -304,11 +312,12 @@ func TestSanitize_KeepExtensionsRemoveUnknownProperties_Success(t *testing.T) { require.NoError(t, err) require.Empty(t, validationErrs, "Input document should be valid") - // Configure to keep ALL extensions but remove unknown properties - // Empty array = keep all extensions, nil = remove all + // Configure to keep ALL extensions using wildcard whitelist opts := &openapi.SanitizeOptions{ - ExtensionPatterns: []string{}, // Empty array = keep ALL extensions - KeepUnknownProperties: false, // Remove unknown properties + ExtensionPatterns: &openapi.ExtensionFilter{ + Keep: []string{"*"}, // Wildcard = keep ALL extensions + }, + KeepUnknownProperties: false, // Remove unknown properties KeepUnusedComponents: true, } @@ -329,3 +338,212 @@ func TestSanitize_KeepExtensionsRemoveUnknownProperties_Success(t *testing.T) { // Compare the actual output with expected output assert.Equal(t, string(expectedBytes), string(actualYAML), "Should keep extensions but remove unknown properties") } + +func TestSanitize_WhitelistMode_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document with various extensions + inputFile, err := os.Open("testdata/sanitize/sanitize_pattern_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") + + // Sanitize with whitelist - only keep x-speakeasy-* extensions + opts := &openapi.SanitizeOptions{ + ExtensionPatterns: &openapi.ExtensionFilter{ + Keep: []string{"x-speakeasy-*"}, + }, + KeepUnusedComponents: true, + KeepUnknownProperties: true, + } + result, err := openapi.Sanitize(ctx, inputDoc, opts) + require.NoError(t, err) + assert.Empty(t, result.Warnings, "Should not have warnings") + + // Marshal the sanitized document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.String() + + // Verify that only x-speakeasy-* extensions remain + assert.Contains(t, actualYAML, "x-speakeasy-", "Should contain x-speakeasy-* extensions") + assert.NotContains(t, actualYAML, "x-go-", "Should not contain x-go-* extensions") +} + +func TestSanitize_WhitelistOverridesBlacklist_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/sanitize/sanitize_multi_pattern_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 all x-speakeasy-* extensions EXCEPT x-speakeasy-schema-* + // This demonstrates whitelist overriding blacklist for a narrower match + opts := &openapi.SanitizeOptions{ + ExtensionPatterns: &openapi.ExtensionFilter{ + Keep: []string{"x-speakeasy-schema-*"}, // Keep only schema-related extensions + Remove: []string{"x-speakeasy-*"}, // Remove all speakeasy extensions + }, + KeepUnusedComponents: true, + } + result, err := openapi.Sanitize(ctx, inputDoc, opts) + require.NoError(t, err) + assert.Empty(t, result.Warnings, "Should not have warnings") + + // Marshal the sanitized document + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.String() + + // Verify whitelist overrides blacklist + // x-speakeasy-schema-* should be kept (matches whitelist) + assert.Contains(t, actualYAML, "x-speakeasy-schema-version", "Should keep x-speakeasy-schema-version (whitelist)") + assert.Contains(t, actualYAML, "x-speakeasy-schema-id", "Should keep x-speakeasy-schema-id (whitelist)") + assert.Contains(t, actualYAML, "x-speakeasy-schema-name", "Should keep x-speakeasy-schema-name (whitelist)") + + // Other x-speakeasy-* should be removed (matches blacklist, not whitelist) + assert.NotContains(t, actualYAML, "x-speakeasy-retries", "Should remove x-speakeasy-retries (blacklist, not in whitelist)") + assert.NotContains(t, actualYAML, "x-speakeasy-pagination", "Should remove x-speakeasy-pagination (blacklist, not in whitelist)") + assert.NotContains(t, actualYAML, "x-speakeasy-entity", "Should remove x-speakeasy-entity (blacklist, not in whitelist)") + + // Non-speakeasy extensions should remain (not affected by either pattern) + assert.Contains(t, actualYAML, "x-go-", "Should keep x-go-* (not affected by patterns)") + assert.Contains(t, actualYAML, "x-internal-", "Should keep x-internal-* (not affected by patterns)") +} + +func TestSanitize_EmptyFilter_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/sanitize/sanitize_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") + + // Empty filter should remove all extensions + opts := &openapi.SanitizeOptions{ + ExtensionPatterns: &openapi.ExtensionFilter{}, + KeepUnusedComponents: true, + } + result, err := openapi.Sanitize(ctx, inputDoc, opts) + require.NoError(t, err) + assert.Empty(t, result.Warnings, "Should not have warnings") + + // Marshal the sanitized document + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.String() + + // Verify all extensions are removed + assert.NotContains(t, actualYAML, "x-go-", "Should not contain any x-go-* extensions") + assert.NotContains(t, actualYAML, "x-speakeasy-", "Should not contain any x-speakeasy-* extensions") +} + +func TestSanitize_WildcardKeep_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/sanitize/sanitize_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") + + // Keep all extensions with wildcard + opts := &openapi.SanitizeOptions{ + ExtensionPatterns: &openapi.ExtensionFilter{ + Keep: []string{"*"}, + }, + KeepUnusedComponents: true, + } + result, err := openapi.Sanitize(ctx, inputDoc, opts) + require.NoError(t, err) + assert.Empty(t, result.Warnings, "Should not have warnings") + + // Marshal the sanitized document + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.String() + + // Verify all extensions are kept + assert.Contains(t, actualYAML, "x-", "Should contain extensions") +} + +func TestSanitize_InvalidKeepPattern_Warning(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/sanitize/sanitize_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") + + // Use invalid glob pattern in Keep + opts := &openapi.SanitizeOptions{ + ExtensionPatterns: &openapi.ExtensionFilter{ + Keep: []string{"x-[invalid-pattern"}, + }, + KeepUnusedComponents: true, + } + result, err := openapi.Sanitize(ctx, inputDoc, opts) + require.NoError(t, err) + assert.NotEmpty(t, result.Warnings, "Should have warnings for invalid pattern") + assert.Contains(t, result.Warnings[0], "invalid keep pattern", "Warning should mention invalid keep pattern") +} + +func TestSanitize_InvalidRemovePattern_Warning(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/sanitize/sanitize_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") + + // Use invalid glob pattern in Remove + opts := &openapi.SanitizeOptions{ + ExtensionPatterns: &openapi.ExtensionFilter{ + Remove: []string{"x-[invalid-pattern"}, + }, + KeepUnusedComponents: true, + } + result, err := openapi.Sanitize(ctx, inputDoc, opts) + require.NoError(t, err) + assert.NotEmpty(t, result.Warnings, "Should have warnings for invalid pattern") + assert.Contains(t, result.Warnings[0], "invalid remove pattern", "Warning should mention invalid remove pattern") +} diff --git a/openapi/testdata/sanitize/sanitize_multi_pattern_expected.yaml b/openapi/testdata/sanitize/sanitize_multi_pattern_expected.yaml index 1050366..138b909 100644 --- a/openapi/testdata/sanitize/sanitize_multi_pattern_expected.yaml +++ b/openapi/testdata/sanitize/sanitize_multi_pattern_expected.yaml @@ -3,6 +3,7 @@ info: title: Multi-Pattern Test API version: 1.0.0 x-speakeasy-retries: 3 + x-speakeasy-schema-version: v1 x-other-extension: value paths: /test: @@ -10,6 +11,7 @@ paths: summary: Test endpoint operationId: test x-speakeasy-pagination: false + x-speakeasy-schema-id: test-op x-rate-limit: 100 responses: "200": @@ -19,6 +21,7 @@ components: TestSchema: type: object x-speakeasy-entity: test + x-speakeasy-schema-name: TestSchema x-custom: value properties: field: diff --git a/openapi/testdata/sanitize/sanitize_multi_pattern_input.yaml b/openapi/testdata/sanitize/sanitize_multi_pattern_input.yaml index fc27d1f..d6b9999 100644 --- a/openapi/testdata/sanitize/sanitize_multi_pattern_input.yaml +++ b/openapi/testdata/sanitize/sanitize_multi_pattern_input.yaml @@ -5,6 +5,7 @@ info: x-go-package: testapi x-internal-version: 2.0 x-speakeasy-retries: 3 + x-speakeasy-schema-version: v1 x-other-extension: value paths: /test: @@ -14,6 +15,7 @@ paths: x-go-name: Test x-internal-cache: true x-speakeasy-pagination: false + x-speakeasy-schema-id: test-op x-rate-limit: 100 responses: "200": @@ -25,6 +27,7 @@ components: x-go-type: TestSchema x-internal-table: test x-speakeasy-entity: test + x-speakeasy-schema-name: TestSchema x-custom: value properties: field: diff --git a/openapi/testdata/sanitize/sanitize_pattern_config.yaml b/openapi/testdata/sanitize/sanitize_pattern_config.yaml index c260a54..6f46de9 100644 --- a/openapi/testdata/sanitize/sanitize_pattern_config.yaml +++ b/openapi/testdata/sanitize/sanitize_pattern_config.yaml @@ -1,2 +1,3 @@ extensionPatterns: - - "x-go-*" + remove: + - "x-go-*"