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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});

Expand All @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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);
});
});
19 changes: 17 additions & 2 deletions projects/standard-rulesets/src/breaking-changes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { preventResponsePropertyOptional } from './preventResponsePropertyOption
import { preventResponsePropertyRemoval } from './preventResponsePropertyRemoval';
import { preventResponsePropertyTypeChange } from './preventResponsePropertyTypeChange';
import { preventResponseStatusCodeRemoval } from './preventResponseStatusCodeRemoval';

import {
preventQueryParameterEnumBreak,
preventCookieParameterEnumBreak,
Expand Down Expand Up @@ -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;
Expand All @@ -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',
Expand Down
41 changes: 33 additions & 8 deletions projects/standard-rulesets/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
};

Loading