Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
53 changes: 53 additions & 0 deletions tools/spectral/ipa/__tests__/metrics/collector.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from '@jest/globals';
import collector, { EntryType } from '../../metrics/collector';
import * as fs from 'node:fs';

jest.mock('node:fs');

describe('Collector Class', () => {
const expectedOutput = {
violations: [
{ componentId: 'example.component', ruleName: 'rule-1' },
{ componentId: 'example.component', ruleName: 'rule-2' },
],
adoptions: [{ componentId: 'example.component', ruleName: 'rule-3' }],
exceptions: [{ componentId: 'example.component', ruleName: 'rule-4' }],
};

beforeEach(() => {
collector.entries = {
[EntryType.VIOLATION]: [],
[EntryType.ADOPTION]: [],
[EntryType.EXCEPTION]: [],
};

jest.clearAllMocks();
});

it('should collect violations, adoptions, and exceptions correctly', () => {
collector.add(['example', 'component'], 'rule-1', EntryType.VIOLATION);
collector.add(['example', 'component'], 'rule-2', EntryType.VIOLATION);
collector.add(['example', 'component'], 'rule-3', EntryType.ADOPTION);
collector.add(['example', 'component'], 'rule-4', EntryType.EXCEPTION);

expect(collector.entries).toEqual(expectedOutput);

collector.flushToFile();
const writtenData = JSON.stringify(expectedOutput, null, 2);
expect(fs.writeFileSync).toHaveBeenCalledWith('combined.log', writtenData);
});

it('should not add invalid entries', () => {
collector.add(null, 'rule-1', EntryType.VIOLATION);
collector.add(['example', 'component'], null, EntryType.ADOPTION);
collector.add(['example', 'component'], 'rule-4', null);

expect(collector.entries).toEqual({
violations: [],
adoptions: [],
exceptions: [],
});

expect(fs.writeFileSync).not.toHaveBeenCalled();
});
});
12 changes: 6 additions & 6 deletions tools/spectral/ipa/__tests__/utils/exceptions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,16 @@ const objectWithIpa100And101Exception = {
describe('tools/spectral/ipa/rulesets/functions/utils/exceptions.js', () => {
describe('hasException', () => {
it('returns true if object has exception matching the rule name', () => {
expect(hasException(objectWithIpa100Exception, TEST_RULE_NAME_100)).toBe(true);
expect(hasException(objectWithIpa100ExceptionAndOwnerExtension, TEST_RULE_NAME_100)).toBe(true);
expect(hasException(objectWithIpa100And101Exception, TEST_RULE_NAME_100)).toBe(true);
expect(hasException(objectWithIpa100Exception, TEST_RULE_NAME_100, '')).toBe(true);
expect(hasException(objectWithIpa100ExceptionAndOwnerExtension, TEST_RULE_NAME_100, '')).toBe(true);
expect(hasException(objectWithIpa100And101Exception, TEST_RULE_NAME_100, '')).toBe(true);
});
it('returns false if object does not have exception matching the rule name', () => {
expect(hasException({}, TEST_RULE_NAME_100)).toBe(false);
expect(hasException(objectWithIpa101Exception, TEST_RULE_NAME_100)).toBe(false);
expect(hasException({}, TEST_RULE_NAME_100, '')).toBe(false);
expect(hasException(objectWithIpa101Exception, TEST_RULE_NAME_100, '')).toBe(false);
});
it('returns false if object has nested exception matching the rule name', () => {
expect(hasException(objectWithNestedIpa100Exception, TEST_RULE_NAME_100)).toBe(false);
expect(hasException(objectWithNestedIpa100Exception, TEST_RULE_NAME_100, '')).toBe(false);
});
});
});
58 changes: 58 additions & 0 deletions tools/spectral/ipa/metrics/collector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as fs from 'node:fs';

export const EntryType = Object.freeze({
EXCEPTION: 'exceptions',
VIOLATION: 'violations',
ADOPTION: 'adoptions',
});

class Collector {
static instance = null;

static getInstance() {
if (!this.instance) {
this.instance = new Collector();
}
return this.instance;
}

constructor() {
if (Collector.instance) {
throw new Error('Use Collector.getInstance()');
}

this.entries = {
[EntryType.VIOLATION]: [],
[EntryType.ADOPTION]: [],
[EntryType.EXCEPTION]: [],
};

this.fileName = 'combined.log';

process.on('exit', () => this.flushToFile());
process.on('SIGINT', () => {
this.flushToFile();
process.exit();
});
}

add(componentId, ruleName, type) {
if (componentId && ruleName && type) {
componentId = componentId.join('.');
const data = { componentId, ruleName };
this.entries[type].push(data);
}
}

flushToFile() {
try {
const data = JSON.stringify(this.entries, null, 2);
fs.writeFileSync(this.fileName, data);
} catch (error) {
console.error('Error writing exceptions to file:', error);
}
}
}

const collector = Collector.getInstance();
export default collector;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isCustomMethod } from './utils/resourceEvaluation.js';
import { hasException } from './utils/exceptions.js';
import collector, { EntryType } from '../../metrics/collector.js';

const RULE_NAME = 'xgen-IPA-109-custom-method-must-be-GET-or-POST';
const ERROR_MESSAGE = 'The HTTP method for custom methods must be GET or POST.';
Expand All @@ -13,7 +14,7 @@ export default (input, opts, { path }) => {

if (!isCustomMethod(pathKey)) return;

if (hasException(input, RULE_NAME)) {
if (hasException(input, RULE_NAME, path)) {
return;
}

Expand All @@ -23,13 +24,17 @@ export default (input, opts, { path }) => {

// Check for invalid methods
if (httpMethods.some((method) => !VALID_METHODS.includes(method))) {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
return ERROR_RESULT;
}

// Check for multiple valid methods
const validMethodCount = httpMethods.filter((method) => VALID_METHODS.includes(method)).length;

if (validMethodCount > 1) {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
return ERROR_RESULT;
}

collector.add(path, RULE_NAME, EntryType.ADOPTION);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getCustomMethodName, isCustomMethod } from './utils/resourceEvaluation.js';
import { hasException } from './utils/exceptions.js';
import { casing } from '@stoplight/spectral-functions';
import collector, { EntryType } from '../../metrics/collector.js';

const RULE_NAME = 'xgen-IPA-109-custom-method-must-use-camel-case';

Expand All @@ -10,16 +11,20 @@ export default (input, opts, { path }) => {

if (!isCustomMethod(pathKey)) return;

if (hasException(input, RULE_NAME)) {
if (hasException(input, RULE_NAME, path)) {
return;
}

let methodName = getCustomMethodName(pathKey);
if (methodName.length === 0 || methodName.trim().length === 0) {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
return [{ message: 'Custom method name cannot be empty or blank.' }];
}

if (casing(methodName, { type: 'camel', disallowDigits: true })) {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
return [{ message: `${methodName} must use camelCase format.` }];
}

collector.add(path, RULE_NAME, EntryType.ADOPTION);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { hasException } from './utils/exceptions.js';
import { resolveObject } from './utils/componentUtils.js';
import { casing } from '@stoplight/spectral-functions';
import collector, { EntryType } from '../../metrics/collector.js';

const RULE_NAME = 'xgen-IPA-123-enum-values-must-be-upper-snake-case';
const ERROR_MESSAGE = 'enum value must be UPPER_SNAKE_CASE.';
Expand All @@ -17,7 +18,7 @@ export default (input, _, { path, documentInventory }) => {
const oas = documentInventory.resolved;
const schemaPath = getSchemaPathFromEnumPath(path);
const schemaObject = resolveObject(oas, schemaPath);
if (hasException(schemaObject, RULE_NAME)) {
if (hasException(schemaObject, RULE_NAME, schemaPath)) {
return;
}

Expand All @@ -33,5 +34,11 @@ export default (input, _, { path, documentInventory }) => {
}
});

if (errors.length === 0) {
collector.add(schemaPath, RULE_NAME, EntryType.ADOPTION);
} else {
collector.add(schemaPath, RULE_NAME, EntryType.VIOLATION);
}

return errors;
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isPathParam } from './utils/componentUtils.js';
import { hasException } from './utils/exceptions.js';
import collector, { EntryType } from '../../metrics/collector.js';

const RULE_NAME = 'xgen-IPA-102-path-alternate-resource-name-path-param';
const ERROR_MESSAGE = 'API paths must alternate between resource name and path params.';
Expand All @@ -24,9 +25,9 @@ const validatePathStructure = (elements) => {
});
};

export default (input, _, { documentInventory }) => {
export default (input, _, { path, documentInventory }) => {
const oas = documentInventory.resolved;
if (hasException(oas.paths[input], RULE_NAME)) {
if (hasException(oas.paths[input], RULE_NAME, path)) {
return;
}

Expand All @@ -43,6 +44,9 @@ export default (input, _, { documentInventory }) => {
let suffix = suffixWithLeadingSlash.slice(1);
let elements = suffix.split('/');
if (!validatePathStructure(elements)) {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
return ERROR_RESULT;
}

collector.add(path, RULE_NAME, EntryType.ADOPTION);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,27 @@ import {
getResourcePaths,
} from './utils/resourceEvaluation.js';
import { hasException } from './utils/exceptions.js';
import collector, { EntryType } from '../../metrics/collector.js';

const RULE_NAME = 'xgen-IPA-104-resource-has-GET';
const ERROR_MESSAGE = 'APIs must provide a get method for resources.';

export default (input, _, { documentInventory }) => {
export default (input, _, { path, documentInventory }) => {
if (isChild(input) || isCustomMethod(input)) {
return;
}

const oas = documentInventory.resolved;

if (hasException(oas.paths[input], RULE_NAME)) {
if (hasException(oas.paths[input], RULE_NAME, path)) {
return;
}

const resourcePaths = getResourcePaths(input, Object.keys(oas.paths));

if (isSingletonResource(resourcePaths)) {
if (!hasGetMethod(oas.paths[resourcePaths[0]])) {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
return [
{
message: ERROR_MESSAGE,
Expand All @@ -34,11 +36,14 @@ export default (input, _, { documentInventory }) => {
}
} else if (isStandardResource(resourcePaths)) {
if (!hasGetMethod(oas.paths[resourcePaths[1]])) {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
return [
{
message: ERROR_MESSAGE,
},
];
}
}

collector.add(path, RULE_NAME, EntryType.ADOPTION);
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import collector, { EntryType } from '../../metrics/collector.js';

const RULE_NAME = 'xgen-IPA-005-exception-extension-format';
const ERROR_MESSAGE = 'IPA exceptions must have a valid rule name and a reason.';
const RULE_NAME_PREFIX = 'xgen-IPA-';

Expand All @@ -16,6 +19,12 @@ export default (input, _, { path }) => {
}
});

if (errors.length === 0) {
collector.add(path, RULE_NAME, EntryType.ADOPTION);
} else {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
}

return errors;
};

Expand Down
6 changes: 5 additions & 1 deletion tools/spectral/ipa/rulesets/functions/singletonHasNoId.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from './utils/resourceEvaluation.js';
import { hasException } from './utils/exceptions.js';
import { getAllSuccessfulGetResponseSchemas } from './utils/methodUtils.js';
import collector, { EntryType } from '../../metrics/collector.js';

const RULE_NAME = 'xgen-IPA-113-singleton-must-not-have-id';
const ERROR_MESSAGE = 'Singleton resources must not have a user-provided or system-generated ID.';
Expand All @@ -18,7 +19,7 @@ export default (input, opts, { path, documentInventory }) => {
return;
}

if (hasException(input, RULE_NAME)) {
if (hasException(input, RULE_NAME, path)) {
return;
}

Expand All @@ -28,13 +29,16 @@ export default (input, opts, { path, documentInventory }) => {
if (isSingletonResource(resourcePaths) && hasGetMethod(input)) {
const resourceSchemas = getAllSuccessfulGetResponseSchemas(input);
if (resourceSchemas.some((schema) => schemaHasIdProperty(schema))) {
collector.add(path, RULE_NAME, EntryType.VIOLATION);
return [
{
message: ERROR_MESSAGE,
},
];
}
}

collector.add(path, RULE_NAME, EntryType.ADOPTION);
};

function schemaHasIdProperty(schema) {
Expand Down
4 changes: 3 additions & 1 deletion tools/spectral/ipa/rulesets/functions/utils/exceptions.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import collector, { EntryType } from '../../../metrics/collector.js';
const EXCEPTION_EXTENSION = 'x-xgen-IPA-exception';

/**
Expand All @@ -7,8 +8,9 @@ const EXCEPTION_EXTENSION = 'x-xgen-IPA-exception';
* @param ruleName the name of the exempted rule
* @returns {boolean} true if the object has an exception named ruleName, otherwise false
*/
export function hasException(object, ruleName) {
export function hasException(object, ruleName, path) {
if (object[EXCEPTION_EXTENSION]) {
collector.add(path, ruleName, EntryType.EXCEPTION);
return Object.keys(object[EXCEPTION_EXTENSION]).includes(ruleName);
}
return false;
Expand Down
Loading