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 package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@babel/preset-env": "^7.26.0",
"@eslint/js": "^9.16.0",
"@jest/globals": "^29.7.0",
"@stoplight/types": "^14.1.1",
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added as I noticed this one wasn't in the package.json, though we use it in the test files

"eslint": "^9.17.0",
"eslint-plugin-require-extensions": "^0.1.3",
"globals": "^15.14.0",
Expand Down
27 changes: 17 additions & 10 deletions tools/spectral/ipa/__tests__/__helpers__/testRule.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,13 @@ import { Spectral, Document } from '@stoplight/spectral-core';
import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver';
import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader';

const rulesetPath = path.join(__dirname, '../..', 'ipa-spectral.yaml');

export default (ruleName, tests) => {
describe(`Rule ${ruleName}`, () => {
for (const testCase of tests) {
it.concurrent(testCase.name, async () => {
const s = await createSpectral();
const s = await createSpectral(ruleName);
const doc = testCase.document instanceof Document ? testCase.document : JSON.stringify(testCase.document);
const allErrors = await s.run(doc);

const errors = getErrorsForRule(allErrors, ruleName);
const errors = await s.run(doc);

expect(errors.length).toEqual(testCase.errors.length);

Expand All @@ -29,12 +25,23 @@ export default (ruleName, tests) => {
});
};

async function createSpectral() {
async function createSpectral(ruleName) {
const rulesetPath = path.join(__dirname, '../../rulesets', ruleName.slice(5, 12) + '.yaml');
const s = new Spectral({ resolver: httpAndFileResolver });
s.setRuleset(await bundleAndLoadRuleset(rulesetPath, { fs, fetch }));
const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })).toJSON();
s.setRuleset(getRulesetForRule(ruleName, ruleset));
return s;
}

function getErrorsForRule(errors, rule) {
return errors.filter((e) => e.code === rule);
/**
* Takes the passed ruleset and returns a ruleset with only the specified rule.
*
* @param ruleName the name of the rule
* @param ruleset the ruleset containing the rule by ruleName and optionally other rules
* @returns {Object} a ruleset with only the rule with name ruleName
*/
function getRulesetForRule(ruleName, ruleset) {
const modifiedRuleset = { rules: {} };
modifiedRuleset.rules[ruleName] = ruleset.rules[ruleName].definition;
return modifiedRuleset;
Comment on lines +28 to +46
Copy link
Collaborator Author

@lovisaberggren lovisaberggren Jan 8, 2025

Choose a reason for hiding this comment

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

Drive-by to create a spectral instance with only the specific rule for the test run

}
214 changes: 214 additions & 0 deletions tools/spectral/ipa/__tests__/singletonHasNoId.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import testRule from './__helpers__/testRule';
import { DiagnosticSeverity } from '@stoplight/types';

testRule('xgen-IPA-113-singleton-must-not-have-id', [
{
name: 'valid resources',
document: {
paths: {
'/standard': {
post: {},
get: {
responses: {
200: {
content: {
version1: {
schema: {
properties: {
id: {},
someProperty: {},
},
type: 'object',
},
},
},
},
},
},
},
'/standard/{exampleId}': {
get: {
responses: {
200: {
content: {
version1: {
schema: {
properties: {
id: {},
someProperty: {},
},
type: 'object',
},
},
},
},
},
},
patch: {},
delete: {},
},
'/singleton1': {
get: {
responses: {
200: {
content: {
version1: {
schema: {
properties: {
someProperty: {},
},
type: 'object',
},
},
},
},
},
},
},
'/singleton2': {
get: {
responses: {
200: {
content: {
version1: {
schema: {
properties: {
someId: {},
someProperty: {},
},
type: 'object',
},
},
},
},
},
},
},
},
},
errors: [],
},
{
name: 'invalid resources',
document: {
paths: {
'/singleton1': {
get: {
responses: {
200: {
content: {
version1: {
schema: {
properties: {
id: {},
someProperty: {},
},
type: 'object',
},
},
},
},
},
},
},
'/singleton2': {
get: {
responses: {
200: {
content: {
version1: {
schema: {
properties: {
_id: {},
someProperty: {},
},
type: 'object',
},
},
},
},
},
},
},
'/singleton3': {
get: {
responses: {
200: {
content: {
version1: {
schema: {
properties: {
someId: {},
someProperty: {},
},
type: 'object',
},
},
version2: {
schema: {
properties: {
id: {},
someProperty: {},
},
type: 'object',
},
},
},
},
},
},
},
},
},
errors: [
{
code: 'xgen-IPA-113-singleton-must-not-have-id',
message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113',
path: ['paths', '/singleton1'],
severity: DiagnosticSeverity.Warning,
},
{
code: 'xgen-IPA-113-singleton-must-not-have-id',
message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113',
path: ['paths', '/singleton2'],
severity: DiagnosticSeverity.Warning,
},
{
code: 'xgen-IPA-113-singleton-must-not-have-id',
message: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113',
path: ['paths', '/singleton3'],
severity: DiagnosticSeverity.Warning,
},
],
},
{
name: 'invalid resources with exceptions',
document: {
paths: {
'/singleton1': {
'x-xgen-IPA-exception': {
'xgen-IPA-113-singleton-must-not-have-id': 'reason',
},
get: {
responses: {
200: {
content: {
version1: {
schema: {
properties: {
id: {},
someProperty: {},
},
type: 'object',
},
},
},
},
},
},
},
},
},
errors: [],
},
]);
3 changes: 2 additions & 1 deletion tools/spectral/ipa/ipa-spectral.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
extends:
- ./rulesets/IPA-005.yaml
- ./rulesets/IPA-102.yaml
- ./rulesets/IPA-104.yaml
- ./rulesets/IPA-005.yaml
- ./rulesets/IPA-109.yaml
- ./rulesets/IPA-113.yaml
- ./rulesets/IPA-123.yaml
14 changes: 14 additions & 0 deletions tools/spectral/ipa/rulesets/IPA-113.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# IPA-113: Singleton Resources
# http://go/ipa/113

functions:
- singletonHasNoId

rules:
xgen-IPA-113-singleton-must-not-have-id:
description: 'Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113'
message: '{{error}} http://go/ipa/113'
severity: warn
given: '$.paths[*]'
then:
function: 'singletonHasNoId'
46 changes: 46 additions & 0 deletions tools/spectral/ipa/rulesets/functions/singletonHasNoId.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
getResourcePaths,
hasGetMethod,
isChild,
isCustomMethod,
isSingletonResource,
} from './utils/resourceEvaluation.js';
import { hasException } from './utils/exceptions.js';
import { getAllSuccessfulGetResponseSchemas } from './utils/methodUtils.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.';

export default (input, opts, { path, documentInventory }) => {
const resourcePath = path[1];

if (isCustomMethod(resourcePath) || isChild(resourcePath)) {
return;
}

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

const oas = documentInventory.resolved;
const resourcePaths = getResourcePaths(resourcePath, Object.keys(oas.paths));

if (isSingletonResource(resourcePaths) && hasGetMethod(input)) {
const resourceSchemas = getAllSuccessfulGetResponseSchemas(input);
if (resourceSchemas.some((schema) => schemaHasIdProperty(schema))) {
return [
{
message: ERROR_MESSAGE,
},
];
}
}
};

function schemaHasIdProperty(schema) {
if (Object.keys(schema).includes('properties')) {
const propertyNames = Object.keys(schema['properties']);
return propertyNames.includes('id') || propertyNames.includes('_id');
}
return false;
}
19 changes: 19 additions & 0 deletions tools/spectral/ipa/rulesets/functions/utils/methodUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Returns a list of all successful response schemas for the 'get' method of the passed resource, i.e. for any 2xx response.
*
* @param {object} pathObject the object for the path
* @returns {Object[]} all 2xx 'get' response schemas
*/
export function getAllSuccessfulGetResponseSchemas(pathObject) {
const responses = pathObject['get']['responses'];
const successfulResponseKey = Object.keys(responses).filter((k) => k.startsWith('2'))[0];
const responseContent = responses[successfulResponseKey]['content'];
const result = [];
Object.keys(responseContent).forEach((k) => {
const schema = responseContent[k]['schema'];
if (schema) {
result.push(schema);
}
});
return result;
}
Loading