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
8 changes: 8 additions & 0 deletions .github/workflows/code-health-tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ jobs:
- name: Run ESLint on JS files
run: |
npm run lint-js
- name: Check IPA docs up-to-date
run: |
npm run gen-ipa-docs
if [[ -n $(git status --porcelain) ]]; then
echo "IPA docs not up to date, please run 'npm run gen-ipa-docs' and commit the changes"
exit 1
fi
exit 0
- name: Install Go
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34
with:
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"format": "npx prettier . --write",
"format-check": "npx prettier . --check",
"lint-js": "npx eslint **/*.js",
"gen-ipa-docs": "node tools/spectral/ipa/scripts/generateRulesetReadme.js",
"ipa-validation": "spectral lint ./openapi/v2.yaml --ruleset=./tools/spectral/ipa/ipa-spectral.yaml",
"test": "jest"
},
Expand All @@ -28,6 +29,7 @@
"apache-arrow": "^19.0.0",
"dotenv": "^16.4.7",
"eslint-plugin-jest": "^28.10.0",
"markdown-table": "^3.0.4",
"openapi-to-postmanv2": "4.25.0",
"parquet-wasm": "^0.6.1"
},
Expand Down
5 changes: 2 additions & 3 deletions tools/spectral/ipa/metrics/metricCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import {
extractTeamOwnership,
getSeverityPerRule,
loadCollectorResults,
loadOpenAPIFile,
loadRuleset,
merge,
} from './utils/metricCollectionUtils.js';
import { loadJsonFile, loadRuleset } from '../utils.js';

export async function runMetricCollectionJob(
{
Expand All @@ -18,7 +17,7 @@ export async function runMetricCollectionJob(
) {
try {
console.log(`Loading OpenAPI file: ${oasFilePath}`);
const oasContent = loadOpenAPIFile(oasFilePath);
const oasContent = loadJsonFile(oasFilePath);

console.log('Extracting team ownership data...');
const ownershipData = extractTeamOwnership(oasContent);
Expand Down
33 changes: 6 additions & 27 deletions tools/spectral/ipa/metrics/utils/metricCollectionUtils.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import fs from 'node:fs';
import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader';
import { EntryType } from '../collector.js';

export function loadOpenAPIFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
throw new Error(`Failed to load OpenAPI file: ${error.message}`);
}
}
import { loadJsonFile } from '../../utils.js';

export function getSeverityPerRule(ruleset) {
const rules = ruleset.rules || {};
Expand All @@ -20,16 +10,6 @@ export function getSeverityPerRule(ruleset) {
return map;
}

export async function loadRuleset(rulesetPath, spectral) {
try {
const ruleset = await bundleAndLoadRuleset(rulesetPath, { fs, fetch });
await spectral.setRuleset(ruleset);
return ruleset;
} catch (error) {
throw new Error(`Failed to load ruleset: ${error.message}`);
}
}

export function extractTeamOwnership(oasContent) {
const ownerTeams = {};
const paths = oasContent.paths || {};
Expand All @@ -53,15 +33,14 @@ export function extractTeamOwnership(oasContent) {

export function loadCollectorResults(collectorResultsFilePath) {
try {
const content = fs.readFileSync(collectorResultsFilePath, 'utf8');
const contentParsed = JSON.parse(content);
const content = loadJsonFile(collectorResultsFilePath);
return {
[EntryType.VIOLATION]: contentParsed[EntryType.VIOLATION],
[EntryType.ADOPTION]: contentParsed[EntryType.ADOPTION],
[EntryType.EXCEPTION]: contentParsed[EntryType.EXCEPTION],
[EntryType.VIOLATION]: content[EntryType.VIOLATION],
[EntryType.ADOPTION]: content[EntryType.ADOPTION],
[EntryType.EXCEPTION]: content[EntryType.EXCEPTION],
};
} catch (error) {
throw new Error(`Failed to load Collector Results file: ${error.message}`);
throw new Error(`Failed to parse Collector Results: ${error.message}`);
}
}

Expand Down
60 changes: 60 additions & 0 deletions tools/spectral/ipa/rulesets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!--- NOTE: This README file is generated, please see /scripts/generateRulesetReadme.js --->

# IPA Validation Rules

All Spectral rules used in the IPA validation are defined in rulesets grouped by IPA number (`IPA-XXX.yaml`). These rulesets are imported into the main IPA ruleset [ipa-spectral.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/ipa-spectral.yaml) which is used for running the validation.

## Rulesets

The tables below lists all available rules, their descriptions and severity level.

### IPA-005

For rule definitions, see [IPA-005.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-005.yaml).

| Rule Name | Description | Severity |
| --------------------------------------- | ------------------------------------------------------------------------ | -------- |
| xgen-IPA-005-exception-extension-format | IPA exception extensions must follow the correct format. http://go/ipa/5 | warn |

### IPA-102

For rule definitions, see [IPA-102.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-102.yaml).

| Rule Name | Description | Severity |
| ---------------------------------------------------- | -------------------------------------------------------------------------------- | -------- |
| xgen-IPA-102-path-alternate-resource-name-path-param | Paths should alternate between resource names and path params. http://go/ipa/102 | warn |

### IPA-104

For rule definitions, see [IPA-104.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-104.yaml).

| Rule Name | Description | Severity |
| ----------------------------- | --------------------------------------------------------------- | -------- |
| xgen-IPA-104-resource-has-GET | APIs must provide a get method for resources. http://go/ipa/104 | warn |

### IPA-109

For rule definitions, see [IPA-109.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-109.yaml).

| Rule Name | Description | Severity |
| ---------------------------------------------- | ------------------------------------------------------------------------- | -------- |
| xgen-IPA-109-custom-method-must-be-GET-or-POST | The HTTP method for custom methods must be GET or POST. http://go/ipa/109 | warn |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Golink to internal site. Might be better to have actions self explanatory. Then downstream can link upstream instead.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO I think it's nice to have a link to the IPA itself for each rule, as it will show up when a validation fails. We do use golinks pretty widely in the openapi repo

| xgen-IPA-109-custom-method-must-use-camel-case | The custom method must use camelCase format. http://go/ipa/109 | warn |

### IPA-113

For rule definitions, see [IPA-113.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-113.yaml).

| Rule Name | Description | Severity |
| --------------------------------------- | ------------------------------------------------------------------------------------------- | -------- |
| xgen-IPA-113-singleton-must-not-have-id | Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113 | warn |

### IPA-123

For rule definitions, see [IPA-123.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-123.yaml).

| Rule Name | Description | Severity |
| ------------------------------------------------- | ------------------------------------------------------- | -------- |
| xgen-IPA-123-enum-values-must-be-upper-snake-case | Enum values must be UPPER_SNAKE_CASE. http://go/ipa/123 | warn |


100 changes: 100 additions & 0 deletions tools/spectral/ipa/scripts/generateRulesetReadme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import fs from 'node:fs';
import path from 'path';
import { fileURLToPath } from 'url';
import spectral from '@stoplight/spectral-core';
import { markdownTable } from 'markdown-table';
import { loadRuleset } from '../utils.js';

const dirname = path.dirname(fileURLToPath(import.meta.url));
const readmeFilePath = path.join(dirname, '../rulesets', 'README.md');

const rulesetsSection = await getRulesetsSection();

const fileContent =
'<!--- NOTE: This README file is generated, please see /scripts/generateRulesetReadme.js --->\n\n' +
'# IPA Validation Rules\n\n' +
'All Spectral rules used in the IPA validation are defined in rulesets grouped by IPA number (`IPA-XXX.yaml`). These rulesets are imported into the main IPA ruleset [ipa-spectral.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/ipa-spectral.yaml) which is used for running the validation.\n\n' +
`${rulesetsSection}` +
'\n';

fs.writeFile(readmeFilePath, fileContent, (error) => {
if (error) {
console.error('Error while generating the IPA rulesets README.md:', error);
process.exit(1);
}
});

async function getRulesetsSection() {
let content =
'## Rulesets\n\n' + 'The tables below lists all available rules, their descriptions and severity level.\n\n';

const rules = await getAllRules();
const ruleNames = Object.keys(rules);
const ipaNumbers = getIpaNumbers(ruleNames);

ipaNumbers.forEach((ipaNumber) => {
const ipaRules = filterRulesByIpaNumber(ipaNumber, rules);
const table = generateRulesetTable(ipaRules);
content +=
`### ${ipaNumber}\n\n` + `For rule definitions, see ${getIpaRulesetUrl(ipaNumber)}.\n\n` + `${table}\n\n`;
});

return content;
}

function generateRulesetTable(rules) {
const table = [['Rule Name', 'Description', 'Severity']];
const tableRows = [];

const ruleNames = Object.keys(rules);
ruleNames.forEach((ruleName) => {
const rule = rules[ruleName];
tableRows.push([ruleName, rule.description, rule.definition.severity]);
});

tableRows.sort(sortBySeverity);
tableRows.forEach((row) => table.push(row));
return markdownTable(table);
}

async function getAllRules() {
const rulesetFilePath = path.join(dirname, '..', 'ipa-spectral.yaml');
const { Spectral } = spectral;
const ruleset = await loadRuleset(rulesetFilePath, new Spectral());
return ruleset.rules;
}

function getIpaNumbers(ruleNames) {
const ipaNumbers = [];
ruleNames.forEach((name) => {
const ipaName = name.substring(5, 12);
if (!ipaNumbers.includes(ipaName)) {
ipaNumbers.push(ipaName);
}
});
return ipaNumbers.sort();
}

function getIpaRulesetUrl(ipaNumber) {
return `[${ipaNumber}.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/${ipaNumber}.yaml)`;
}

function filterRulesByIpaNumber(ipaNumber, rules) {
return Object.keys(rules)
.filter((key) => key.includes(ipaNumber))
.reduce((obj, key) => {
return {
...obj,
[key]: rules[key],
};
}, {});
}

function sortBySeverity(a, b) {
if (a[2] < b[2]) {
return -1;
} else if (a[2] > b[2]) {
return 1;
}
return 0;
}
21 changes: 21 additions & 0 deletions tools/spectral/ipa/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader';
import fs from 'node:fs';

export async function loadRuleset(rulesetPath, spectral) {
try {
const ruleset = await bundleAndLoadRuleset(rulesetPath, { fs, fetch });
await spectral.setRuleset(ruleset);
return ruleset;
} catch (error) {
throw new Error(`Failed to load ruleset: ${error.message}`);
}
}

export function loadJsonFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
throw new Error(`Failed to load file: ${error.message}`);
}
}
Loading