diff --git a/projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts b/projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts index ca0be0ba33..d747509a8e 100644 --- a/projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts +++ b/projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts @@ -11,6 +11,7 @@ describe('fromOpticConfig', () => { expect(out) .toEqual(`- ruleset/breaking-changes/exclude_operations_with_extension must be string - ruleset/breaking-changes/exclude_operations_with_extension must be array +- ruleset/breaking-changes/exclude_operations_with_extension must be array - ruleset/breaking-changes/exclude_operations_with_extension must match exactly one schema in oneOf`); }); @@ -34,6 +35,18 @@ describe('fromOpticConfig', () => { expect(out).toBeInstanceOf(BreakingChangesRuleset); }); + test('valid config with exclude_operations_with_extension as object', async () => { + const out = await BreakingChangesRuleset.fromOpticConfig({ + exclude_operations_with_extension: [ + { + 'x-stability': ['beta', 'alpha', 'draft'], + }, + ], + }); + + expect(out).toBeInstanceOf(BreakingChangesRuleset); + }); + test('does not throw breaking change if semvar has updated', async () => { const out = await BreakingChangesRuleset.fromOpticConfig({ skip_when_major_version_changes: true, @@ -1234,7 +1247,7 @@ describe('breaking changes ruleset', () => { }); describe('breaking change ruleset configuration', () => { - test('breaking changes applies a matches function', async () => { + test('breaking changes applies a matches function for string extension', async () => { const beforeJson: OpenAPIV3.Document = { ...TestHelpers.createEmptySpec(), paths: { @@ -1261,13 +1274,129 @@ describe('breaking change ruleset configuration', () => { }; const results = await TestHelpers.runRulesWithInputs( [ - BreakingChangesRuleset.fromOpticConfig({ + await BreakingChangesRuleset.fromOpticConfig({ exclude_operations_with_extension: 'x-legacy', }) as any, ], beforeJson, afterJson ); - expect(results.length === 0).toBe(true); + expect(results.length).toBe(0); + }); + + test('breaking changes applies a matches function for object extension value match', async () => { + const beforeJson: OpenAPIV3.Document = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + responses: {}, + }, + post: { + 'x-stability-level': 'draft', + responses: {}, + } as any, + }, + }, + }; + const afterJson: OpenAPIV3.Document = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + responses: {}, + }, + }, + }, + }; + const results = await TestHelpers.runRulesWithInputs( + [ + await BreakingChangesRuleset.fromOpticConfig({ + exclude_operations_with_extension: [ + { 'x-stability-level': ['draft'] }, + ], + }) as any, + ], + beforeJson, + afterJson + ); + expect(results.length).toBe(0) + }); + + test('breaking changes applies a matches function for object extension value mismatch', async () => { + const beforeJson: OpenAPIV3.Document = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + responses: {}, + }, + post: { + 'x-stability-level': 'stable', + responses: {}, + } as any, + }, + }, + }; + const afterJson: OpenAPIV3.Document = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + responses: {}, + }, + }, + }, + }; + const results = await TestHelpers.runRulesWithInputs( + [ + await BreakingChangesRuleset.fromOpticConfig({ + exclude_operations_with_extension: [ + { 'x-stability-level': ['draft'] }, + ], + }) as any, + ], + beforeJson, + afterJson + ); + expect(results.length).toEqual(1); + }); + + test('breaking changes applies a matches function for object extension value missing', async () => { + const beforeJson: OpenAPIV3.Document = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + responses: {}, + }, + post: { + responses: {}, + } as any, + }, + }, + }; + const afterJson: OpenAPIV3.Document = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + responses: {}, + }, + }, + }, + }; + const results = await TestHelpers.runRulesWithInputs( + [ + await BreakingChangesRuleset.fromOpticConfig({ + exclude_operations_with_extension: [ + { 'x-stability-level': ['draft'] }, + ], + }) as any, + ], + beforeJson, + afterJson + ); + expect(results.length).toEqual(1); }); }); diff --git a/projects/standard-rulesets/src/breaking-changes/index.ts b/projects/standard-rulesets/src/breaking-changes/index.ts index 850394f6a1..ebae88e90e 100644 --- a/projects/standard-rulesets/src/breaking-changes/index.ts +++ b/projects/standard-rulesets/src/breaking-changes/index.ts @@ -6,6 +6,7 @@ import { preventResponsePropertyOptional } from './preventResponsePropertyOption import { preventResponsePropertyRemoval } from './preventResponsePropertyRemoval'; import { preventResponsePropertyTypeChange } from './preventResponsePropertyTypeChange'; import { preventResponseStatusCodeRemoval } from './preventResponseStatusCodeRemoval'; + import { preventQueryParameterEnumBreak, preventCookieParameterEnumBreak, @@ -36,7 +37,7 @@ import { preventResponseNarrowingInUnionTypes } from './preventResponseNarrowing import { excludeOperationWithExtensionMatches } from '../utils'; type YamlConfig = { - exclude_operations_with_extension?: string | string[]; + exclude_operations_with_extension?: string | string[] | { [key: string]: string[] }[]; skip_when_major_version_changes?: boolean; docs_link?: string; severity?: SeverityText; @@ -47,7 +48,21 @@ const configSchema = { type: 'object', properties: { exclude_operations_with_extension: { - oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' }}, + { + type: 'array', + items: { + type: 'object', + minProperties: 1, + additionalProperties: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + ], }, skip_when_major_version_changes: { type: 'boolean', diff --git a/projects/standard-rulesets/src/utils.ts b/projects/standard-rulesets/src/utils.ts index 4948205cab..26f5561c8f 100644 --- a/projects/standard-rulesets/src/utils.ts +++ b/projects/standard-rulesets/src/utils.ts @@ -10,15 +10,40 @@ function extensionIsTruthy(extension: any) { } export const excludeOperationWithExtensionMatches = ( - excludeOperationWithExtensions: string | string[] + excludeOperationWithExtensions: string | string[] | { [key: string]: string[] }[] ) => { return (context: RuleContext): boolean => { - return Array.isArray(excludeOperationWithExtensions) - ? excludeOperationWithExtensions.some( - (e) => !extensionIsTruthy((context.operation.raw as any)[e]) - ) - : !extensionIsTruthy( - (context.operation.raw as any)[excludeOperationWithExtensions] - ); + const operation = context.operation.raw as any; + + // Case 1: A single extension string (e.g., 'x-legacy') + if (typeof excludeOperationWithExtensions === 'string') { + return !extensionIsTruthy(operation[excludeOperationWithExtensions]); + } + + // Case 2: An array of extensions + if (Array.isArray(excludeOperationWithExtensions)) { + for (const exclusion of excludeOperationWithExtensions) { + // Case 2a: Array of strings (e.g., ['x-legacy', 'x-internal']) + if (typeof exclusion === 'string') { + if (extensionIsTruthy(operation[exclusion])) { + return false; // Exclude if any extension is truthy + } + } + // Case 2b: Array of objects (e.g., [{ 'x-stability': ['beta'] }]) + else if (typeof exclusion === 'object' && exclusion !== null) { + for (const [key, values] of Object.entries(exclusion)) { + const extensionValue = operation[key]; + if ( + extensionValue && + values.includes(String(extensionValue)) + ) { + return false; // Exclude if the extension value matches + } + } + } + } + } + return true; // Include by default }; }; +