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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@
*.vscode

*.out
**/*ipa-collector-results-combined.log
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,9 +1,9 @@
import { isCustomMethod } from './utils/resourceEvaluation.js';
import { hasException } from './utils/exceptions.js';
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.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.';
const ERROR_RESULT = [{ message: ERROR_MESSAGE }];
const VALID_METHODS = ['get', 'post'];
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];

Expand All @@ -14,6 +14,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 +24,15 @@ export default (input, opts, { path }) => {

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

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

if (validMethodCount > 1) {
return ERROR_RESULT;
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
}

collectAdoption(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 { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js';

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

Expand All @@ -11,15 +12,20 @@ export default (input, opts, { path }) => {
if (!isCustomMethod(pathKey)) return;

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

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

if (casing(methodName, { type: 'camel', disallowDigits: true })) {
return [{ message: `${methodName} must use camelCase format.` }];
const errorMessage = `${methodName} must use camelCase format.`;
return collectAndReturnViolation(path, RULE_NAME, errorMessage);
}

collectAdoption(path, RULE_NAME);
};
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 { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.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, schemaPath);
return;
}

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

return errors;
if (errors.length === 0) {
collectAdoption(schemaPath, RULE_NAME);
} else {
return collectAndReturnViolation(schemaPath, RULE_NAME, errors);
}
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { isPathParam } from './utils/componentUtils.js';
import { hasException } from './utils/exceptions.js';
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.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.';
const ERROR_RESULT = [{ message: ERROR_MESSAGE }];
const AUTH_PREFIX = '/api/atlas/v2';
const UNAUTH_PREFIX = '/api/atlas/v2/unauth';

Expand All @@ -24,9 +24,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 +44,8 @@ export default (input, _, { documentInventory }) => {
let suffix = suffixWithLeadingSlash.slice(1);
let elements = suffix.split('/');
if (!validatePathStructure(elements)) {
return ERROR_RESULT;
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
}

collectAdoption(path, RULE_NAME);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,34 @@ import {
getResourcePaths,
} from './utils/resourceEvaluation.js';
import { hasException } from './utils/exceptions.js';
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.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)) {
collectException(oas.paths[input], RULE_NAME, path);
return;
}

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

if (isSingletonResource(resourcePaths)) {
if (!hasGetMethod(oas.paths[resourcePaths[0]])) {
return [
{
message: ERROR_MESSAGE,
},
];
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
}
} else if (isStandardResource(resourcePaths)) {
if (!hasGetMethod(oas.paths[resourcePaths[1]])) {
return [
{
message: ERROR_MESSAGE,
},
];
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
}
}

collectAdoption(path, RULE_NAME);
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { collectAdoption, collectAndReturnViolation } from './utils/collectionUtils.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) {
collectAdoption(path, RULE_NAME);
} else {
return collectAndReturnViolation(path, RULE_NAME, errors);
}

return errors;
};

Expand Down
10 changes: 5 additions & 5 deletions 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 { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.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,11 @@ export default (input, opts, { path, documentInventory }) => {
if (isSingletonResource(resourcePaths) && hasGetMethod(input)) {
const resourceSchemas = getAllSuccessfulGetResponseSchemas(input);
if (resourceSchemas.some((schema) => schemaHasIdProperty(schema))) {
return [
{
message: ERROR_MESSAGE,
},
];
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
}
}

collectAdoption(path, RULE_NAME);
};

function schemaHasIdProperty(schema) {
Expand Down
Loading
Loading