Skip to content

Commit 5cc211f

Browse files
CLOUDP-290417: [Product Metrics/Observability] Implement Collector class (#352)
1 parent c3e8073 commit 5cc211f

12 files changed

+217
-26
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@
2020
*.vscode
2121

2222
*.out
23+
**/*ipa-collector-results-combined.log
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { beforeEach, describe, expect, it } from '@jest/globals';
2+
import collector, { EntryType } from '../../metrics/collector';
3+
import * as fs from 'node:fs';
4+
5+
jest.mock('node:fs');
6+
7+
describe('Collector Class', () => {
8+
const expectedOutput = {
9+
violations: [
10+
{ componentId: 'example.component', ruleName: 'rule-1' },
11+
{ componentId: 'example.component', ruleName: 'rule-2' },
12+
],
13+
adoptions: [{ componentId: 'example.component', ruleName: 'rule-3' }],
14+
exceptions: [{ componentId: 'example.component', ruleName: 'rule-4', exceptionReason: 'exception-reason' }],
15+
};
16+
17+
beforeEach(() => {
18+
collector.entries = {
19+
[EntryType.VIOLATION]: [],
20+
[EntryType.ADOPTION]: [],
21+
[EntryType.EXCEPTION]: [],
22+
};
23+
24+
jest.clearAllMocks();
25+
});
26+
27+
it('should collect violations, adoptions, and exceptions correctly', () => {
28+
collector.add(EntryType.VIOLATION, ['example', 'component'], 'rule-1');
29+
collector.add(EntryType.VIOLATION, ['example', 'component'], 'rule-2');
30+
collector.add(EntryType.ADOPTION, ['example', 'component'], 'rule-3');
31+
collector.add(EntryType.EXCEPTION, ['example', 'component'], 'rule-4', 'exception-reason');
32+
33+
expect(collector.entries).toEqual(expectedOutput);
34+
35+
collector.flushToFile();
36+
const writtenData = JSON.stringify(expectedOutput, null, 2);
37+
expect(fs.writeFileSync).toHaveBeenCalledWith('ipa-collector-results-combined.log', writtenData);
38+
});
39+
40+
it('should not add invalid entries', () => {
41+
collector.add(null, 'rule-1', EntryType.VIOLATION);
42+
collector.add(['example', 'component'], null, EntryType.ADOPTION);
43+
collector.add(['example', 'component'], 'rule-4', null);
44+
45+
expect(collector.entries).toEqual({
46+
violations: [],
47+
adoptions: [],
48+
exceptions: [],
49+
});
50+
51+
expect(fs.writeFileSync).not.toHaveBeenCalled();
52+
});
53+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as fs from 'node:fs';
2+
3+
export const EntryType = Object.freeze({
4+
EXCEPTION: 'exceptions',
5+
VIOLATION: 'violations',
6+
ADOPTION: 'adoptions',
7+
});
8+
9+
class Collector {
10+
static instance = null;
11+
12+
static getInstance() {
13+
if (!this.instance) {
14+
this.instance = new Collector();
15+
}
16+
return this.instance;
17+
}
18+
19+
constructor() {
20+
if (Collector.instance) {
21+
throw new Error('Use Collector.getInstance()');
22+
}
23+
24+
this.entries = {
25+
[EntryType.VIOLATION]: [],
26+
[EntryType.ADOPTION]: [],
27+
[EntryType.EXCEPTION]: [],
28+
};
29+
30+
this.fileName = 'ipa-collector-results-combined.log';
31+
32+
process.on('exit', () => this.flushToFile());
33+
process.on('SIGINT', () => {
34+
this.flushToFile();
35+
process.exit();
36+
});
37+
}
38+
39+
add(type, componentId, ruleName, exceptionReason = null) {
40+
if (componentId && ruleName && type) {
41+
if (!Object.values(EntryType).includes(type)) {
42+
throw new Error(`Invalid entry type: ${type}`);
43+
}
44+
45+
componentId = componentId.join('.');
46+
const entry = { componentId, ruleName };
47+
48+
if (type === EntryType.EXCEPTION && exceptionReason) {
49+
entry.exceptionReason = exceptionReason;
50+
}
51+
52+
this.entries[type].push(entry);
53+
}
54+
}
55+
56+
flushToFile() {
57+
try {
58+
const data = JSON.stringify(this.entries, null, 2);
59+
fs.writeFileSync(this.fileName, data);
60+
} catch (error) {
61+
console.error('Error writing exceptions to file:', error);
62+
}
63+
}
64+
}
65+
66+
const collector = Collector.getInstance();
67+
export default collector;

tools/spectral/ipa/rulesets/functions/eachCustomMethodMustBeGetOrPost.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { isCustomMethod } from './utils/resourceEvaluation.js';
22
import { hasException } from './utils/exceptions.js';
3+
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js';
34

45
const RULE_NAME = 'xgen-IPA-109-custom-method-must-be-GET-or-POST';
56
const ERROR_MESSAGE = 'The HTTP method for custom methods must be GET or POST.';
6-
const ERROR_RESULT = [{ message: ERROR_MESSAGE }];
77
const VALID_METHODS = ['get', 'post'];
88
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
99

@@ -14,6 +14,7 @@ export default (input, opts, { path }) => {
1414
if (!isCustomMethod(pathKey)) return;
1515

1616
if (hasException(input, RULE_NAME)) {
17+
collectException(input, RULE_NAME, path);
1718
return;
1819
}
1920

@@ -23,13 +24,15 @@ export default (input, opts, { path }) => {
2324

2425
// Check for invalid methods
2526
if (httpMethods.some((method) => !VALID_METHODS.includes(method))) {
26-
return ERROR_RESULT;
27+
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
2728
}
2829

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

3233
if (validMethodCount > 1) {
33-
return ERROR_RESULT;
34+
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
3435
}
36+
37+
collectAdoption(path, RULE_NAME);
3538
};

tools/spectral/ipa/rulesets/functions/eachCustomMethodMustUseCamelCase.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getCustomMethodName, isCustomMethod } from './utils/resourceEvaluation.js';
22
import { hasException } from './utils/exceptions.js';
33
import { casing } from '@stoplight/spectral-functions';
4+
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js';
45

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

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

1314
if (hasException(input, RULE_NAME)) {
15+
collectException(input, RULE_NAME, path);
1416
return;
1517
}
1618

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

2225
if (casing(methodName, { type: 'camel', disallowDigits: true })) {
23-
return [{ message: `${methodName} must use camelCase format.` }];
26+
const errorMessage = `${methodName} must use camelCase format.`;
27+
return collectAndReturnViolation(path, RULE_NAME, errorMessage);
2428
}
29+
30+
collectAdoption(path, RULE_NAME);
2531
};

tools/spectral/ipa/rulesets/functions/eachEnumValueMustBeUpperSnakeCase.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { hasException } from './utils/exceptions.js';
22
import { resolveObject } from './utils/componentUtils.js';
33
import { casing } from '@stoplight/spectral-functions';
4+
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js';
45

56
const RULE_NAME = 'xgen-IPA-123-enum-values-must-be-upper-snake-case';
67
const ERROR_MESSAGE = 'enum value must be UPPER_SNAKE_CASE.';
@@ -18,6 +19,7 @@ export default (input, _, { path, documentInventory }) => {
1819
const schemaPath = getSchemaPathFromEnumPath(path);
1920
const schemaObject = resolveObject(oas, schemaPath);
2021
if (hasException(schemaObject, RULE_NAME)) {
22+
collectException(schemaObject, RULE_NAME, schemaPath);
2123
return;
2224
}
2325

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

36-
return errors;
38+
if (errors.length === 0) {
39+
collectAdoption(schemaPath, RULE_NAME);
40+
} else {
41+
return collectAndReturnViolation(schemaPath, RULE_NAME, errors);
42+
}
3743
};

tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { isPathParam } from './utils/componentUtils.js';
22
import { hasException } from './utils/exceptions.js';
3+
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js';
34

45
const RULE_NAME = 'xgen-IPA-102-path-alternate-resource-name-path-param';
56
const ERROR_MESSAGE = 'API paths must alternate between resource name and path params.';
6-
const ERROR_RESULT = [{ message: ERROR_MESSAGE }];
77
const AUTH_PREFIX = '/api/atlas/v2';
88
const UNAUTH_PREFIX = '/api/atlas/v2/unauth';
99

@@ -24,9 +24,10 @@ const validatePathStructure = (elements) => {
2424
});
2525
};
2626

27-
export default (input, _, { documentInventory }) => {
27+
export default (input, _, { path, documentInventory }) => {
2828
const oas = documentInventory.resolved;
2929
if (hasException(oas.paths[input], RULE_NAME)) {
30+
collectException(oas.paths[input], RULE_NAME, path);
3031
return;
3132
}
3233

@@ -43,6 +44,8 @@ export default (input, _, { documentInventory }) => {
4344
let suffix = suffixWithLeadingSlash.slice(1);
4445
let elements = suffix.split('/');
4546
if (!validatePathStructure(elements)) {
46-
return ERROR_RESULT;
47+
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
4748
}
49+
50+
collectAdoption(path, RULE_NAME);
4851
};

tools/spectral/ipa/rulesets/functions/eachResourceHasGetMethod.js

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,34 @@ import {
77
getResourcePaths,
88
} from './utils/resourceEvaluation.js';
99
import { hasException } from './utils/exceptions.js';
10+
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js';
1011

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

14-
export default (input, _, { documentInventory }) => {
15+
export default (input, _, { path, documentInventory }) => {
1516
if (isChild(input) || isCustomMethod(input)) {
1617
return;
1718
}
1819

1920
const oas = documentInventory.resolved;
2021

2122
if (hasException(oas.paths[input], RULE_NAME)) {
23+
collectException(oas.paths[input], RULE_NAME, path);
2224
return;
2325
}
2426

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

2729
if (isSingletonResource(resourcePaths)) {
2830
if (!hasGetMethod(oas.paths[resourcePaths[0]])) {
29-
return [
30-
{
31-
message: ERROR_MESSAGE,
32-
},
33-
];
31+
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
3432
}
3533
} else if (isStandardResource(resourcePaths)) {
3634
if (!hasGetMethod(oas.paths[resourcePaths[1]])) {
37-
return [
38-
{
39-
message: ERROR_MESSAGE,
40-
},
41-
];
35+
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
4236
}
4337
}
38+
39+
collectAdoption(path, RULE_NAME);
4440
};

tools/spectral/ipa/rulesets/functions/exceptionExtensionFormat.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { collectAdoption, collectAndReturnViolation } from './utils/collectionUtils.js';
2+
3+
const RULE_NAME = 'xgen-IPA-005-exception-extension-format';
14
const ERROR_MESSAGE = 'IPA exceptions must have a valid rule name and a reason.';
25
const RULE_NAME_PREFIX = 'xgen-IPA-';
36

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

22+
if (errors.length === 0) {
23+
collectAdoption(path, RULE_NAME);
24+
} else {
25+
return collectAndReturnViolation(path, RULE_NAME, errors);
26+
}
27+
1928
return errors;
2029
};
2130

tools/spectral/ipa/rulesets/functions/singletonHasNoId.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from './utils/resourceEvaluation.js';
88
import { hasException } from './utils/exceptions.js';
99
import { getAllSuccessfulGetResponseSchemas } from './utils/methodUtils.js';
10+
import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js';
1011

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

2122
if (hasException(input, RULE_NAME)) {
23+
collectException(input, RULE_NAME, path);
2224
return;
2325
}
2426

@@ -28,13 +30,11 @@ export default (input, opts, { path, documentInventory }) => {
2830
if (isSingletonResource(resourcePaths) && hasGetMethod(input)) {
2931
const resourceSchemas = getAllSuccessfulGetResponseSchemas(input);
3032
if (resourceSchemas.some((schema) => schemaHasIdProperty(schema))) {
31-
return [
32-
{
33-
message: ERROR_MESSAGE,
34-
},
35-
];
33+
return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE);
3634
}
3735
}
36+
37+
collectAdoption(path, RULE_NAME);
3838
};
3939

4040
function schemaHasIdProperty(schema) {

0 commit comments

Comments
 (0)