Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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', exceptionReason: 'exception-reason' }],
};

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

jest.clearAllMocks();
});

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

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

collector.flushToFile();
const writtenData = JSON.stringify(expectedOutput, null, 2);
expect(fs.writeFileSync).toHaveBeenCalledWith('ipa-collector-results-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();
});
});
67 changes: 67 additions & 0 deletions tools/spectral/ipa/metrics/collector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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 = 'ipa-collector-results-combined.log';

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

add(type, componentId, ruleName, exceptionReason = null ) {
if (componentId && ruleName && type) {
if (!Object.values(EntryType).includes(type)) {
throw new Error(`Invalid entry type: ${type}`);
}

componentId = componentId.join('.');
const entry = {componentId, ruleName};

if(type === EntryType.EXCEPTION && exceptionReason) {
entry.exceptionReason = exceptionReason;
}

this.entries[type].push(entry);
}
}

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 { collectException, 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 @@ -14,6 +15,7 @@ export default (input, opts, { path }) => {
if (!isCustomMethod(pathKey)) return;

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

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

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

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

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

collector.add(EntryType.ADOPTION, path, RULE_NAME);
};
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(EntryType.VIOLATION, path, RULE_NAME);
return [{ message: 'Custom method name cannot be empty or blank.' }];
}

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

collector.add(EntryType.ADOPTION, path, RULE_NAME);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { hasException } from './utils/exceptions.js';
import { collectException, 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 @@ -18,6 +19,7 @@ export default (input, _, { path, documentInventory }) => {
const schemaPath = getSchemaPathFromEnumPath(path);
const schemaObject = resolveObject(oas, schemaPath);
if (hasException(schemaObject, RULE_NAME)) {
collectException(schemaObject, RULE_NAME, path);
return;
}

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

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

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 { collectException, 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,10 @@ const validatePathStructure = (elements) => {
});
};

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

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

collector.add(EntryType.ADOPTION, path, RULE_NAME);
};
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(EntryType.VIOLATION, path, RULE_NAME);
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(EntryType.VIOLATION, path, RULE_NAME);
return [
{
message: ERROR_MESSAGE,
},
];
}
}

collector.add(EntryType.ADOPTION, path, RULE_NAME);
};
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(EntryType.ADOPTION, path, RULE_NAME);
} else {
collector.add(EntryType.VIOLATION, path, RULE_NAME);
}

return errors;
};

Expand Down
7 changes: 6 additions & 1 deletion tools/spectral/ipa/rulesets/functions/singletonHasNoId.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {
isCustomMethod,
isSingletonResource,
} from './utils/resourceEvaluation.js';
import { hasException } from './utils/exceptions.js';
import { collectException, 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 @@ -19,6 +20,7 @@ export default (input, opts, { path, documentInventory }) => {
}

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

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

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

function schemaHasIdProperty(schema) {
Expand Down
16 changes: 16 additions & 0 deletions tools/spectral/ipa/rulesets/functions/utils/exceptions.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import collector, { EntryType } from '../../../metrics/collector.js';

const EXCEPTION_EXTENSION = 'x-xgen-IPA-exception';

/**
Expand All @@ -13,3 +15,17 @@ export function hasException(object, ruleName) {
}
return false;
}

/**
* Collects the information about the object if the object has an exception defined for the given rule
*
* @param object the object to evaluate
* @param ruleName the name of the exempted rule
* @param path the JSON path to the object
*/
export function collectException(object, ruleName, path) {
let exceptionReason = object[EXCEPTION_EXTENSION][ruleName];
if(exceptionReason) {
collector.add(EntryType.EXCEPTION, path, ruleName, exceptionReason);
}
}
Loading