@ibm-cloud/openapi-ruleset
Version:
Spectral ruleset for validating IBM Cloud services
545 lines (467 loc) • 18.6 kB
JavaScript
/**
* Copyright 2024 - 2025 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/
const { wellDefinedDictionaries } = require('../../src/rules');
const {
makeCopy,
rootDocument,
testRule,
severityCodes,
} = require('../test-utils');
const rule = wellDefinedDictionaries;
const ruleId = 'ibm-well-defined-dictionaries';
const expectedSeverity = severityCodes.warning;
describe(`Spectral rule: ${ruleId}`, () => {
describe('Should not yield errors', () => {
// The spec includes scalar values and models,
// neither of which should trigger warnings with this rule.
it('Clean spec', async () => {
const results = await testRule(ruleId, rule, rootDocument);
expect(results).toHaveLength(0);
});
it('Includes a well-defined dictionary with scalar values (additionalProperties)', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary mapping keys to string values',
type: 'object',
additionalProperties: {
type: 'string',
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('Includes a well-defined dictionary with scalar values (patternProperties)', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary mapping keys to string values',
type: 'object',
patternProperties: {
'^[a-zA-Z]+$': {
type: 'string',
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('Includes a well-defined dictionary with composed scalar values', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary mapping keys to integer values',
type: 'object',
additionalProperties: {
oneOf: [
{
type: 'integer',
minimum: 0,
maximum: 10,
},
{
type: 'integer',
minimum: 20,
maximum: 30,
},
],
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('Includes a well-defined composed dictionary schema with scalar values', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
oneOf: [
{
description: 'a dictionary mapping keys to string values',
type: 'object',
additionalProperties: {
type: 'string',
maxLength: 24,
},
},
{
description: 'a dictionary mapping keys to string values',
type: 'object',
additionalProperties: {
type: 'string',
maxLength: 42,
},
},
],
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('Includes a well-defined dictionary of models that include a dictionary property', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary mapping keys to string values',
type: 'object',
additionalProperties: {
type: 'object',
properties: {
nested: {
type: 'object',
additionalProperties: {
type: 'string',
},
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
});
describe('Should yield errors', () => {
const expectedPaths = [
'paths./v1/movies.get.responses.200.content.application/json.schema.allOf.1.properties.movies.items.properties.metadata',
'paths./v1/movies.post.responses.201.content.application/json.schema.properties.metadata',
'paths./v1/movies/{movie_id}.get.responses.200.content.application/json.schema.properties.metadata',
'paths./v1/movies/{movie_id}.put.responses.200.content.application/json.schema.properties.metadata',
];
it('Includes an object with nothing defined', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'an object with no definition',
type: 'object',
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Object schemas must define either properties, or (additional/pattern)Properties with a concrete type'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a model/dictionary hybrid, which is not allowed (additionalProperties)', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
properties: {
rating: {
type: 'integer',
},
},
additionalProperties: {
type: 'string',
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Object schemas must be either a model or a dictionary - they cannot be both'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a model/dictionary hybrid, which is not allowed (patternProperties)', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
properties: {
rating: {
type: 'integer',
},
},
patternProperties: {
'^[a-zA-Z]+$': {
type: 'string',
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Object schemas must be either a model or a dictionary - they cannot be both'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a dictionary with additionalProperties set to "true"', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
additionalProperties: true,
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionary schemas must have a single, well-defined value type'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a dictionary with patternProperties value that has no type', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
patternProperties: {
'^[a-zA-Z]+$': {
type: 'string',
},
'^wrong_[A-Z][a-z]+$': {
description: 'no type definition',
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionary schemas must have a single, well-defined value type'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a dictionary with additionalProperties set to an empty object', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
additionalProperties: {},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionary schemas must have a single, well-defined value type'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a nested dictionary with additionalProperties set to "true"', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
additionalProperties: {
type: 'object',
additionalProperties: true,
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
const expectedRulePaths = [
'paths./v1/movies.get.responses.200.content.application/json.schema.allOf.1.properties.movies.items.properties.metadata',
'paths./v1/movies.post.responses.201.content.application/json.schema.properties.metadata',
'paths./v1/movies/{movie_id}.get.responses.200.content.application/json.schema.properties.metadata',
'paths./v1/movies/{movie_id}.put.responses.200.content.application/json.schema.properties.metadata',
];
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionaries must not have values that are also dictionaries'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedRulePaths[i]);
}
});
it('Includes a composed dictionary schema with additionalProperties set to true', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
allOf: [
{
type: 'object',
additionalProperties: true,
},
{
description: 'a dictionary with no definition',
},
],
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionary schemas must have a single, well-defined value type'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a dictionary of dictionaries, which is not allowed (additionalProperties)', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
additionalProperties: {
type: 'object',
additionalProperties: {
type: 'string',
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionaries must not have values that are also dictionaries'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a dictionary of dictionaries, which is not allowed (patternProperties)', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
patternProperties: {
'^[a-zA-Z]+$': {
type: 'object',
patternProperties: {
'^[a-zA-Z]+$': {
type: 'string',
},
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionaries must not have values that are also dictionaries'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a dictionary of dictionaries, which is not allowed (hybrid)', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
patternProperties: {
'^[a-zA-Z]+$': {
type: 'object',
additionalProperties: true,
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionaries must not have values that are also dictionaries'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Flags dictionary of dictionaries but ignores dictionary value issue', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
additionalProperties: {
type: 'object',
additionalProperties: true,
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionaries must not have values that are also dictionaries'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a composed dictionary of dictionaries, which is not allowed', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary with no definition',
type: 'object',
additionalProperties: {
type: 'object',
allOf: [
{
additionalProperties: {
type: 'string',
},
},
{
properties: {
name: {
type: 'string',
},
},
},
],
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionaries must not have values that are also dictionaries'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
it('Includes a well-defined dictionary of models that include a poorly-defined dictionary property', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.components.schemas.Movie.properties.metadata = {
description: 'a dictionary mapping keys to string values',
type: 'object',
additionalProperties: {
type: 'object',
properties: {
nested: {
type: 'object',
additionalProperties: true,
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(4);
const expectedRulePaths = [
'paths./v1/movies.get.responses.200.content.application/json.schema.allOf.1.properties.movies.items.properties.metadata.additionalProperties.properties.nested',
'paths./v1/movies.post.responses.201.content.application/json.schema.properties.metadata.additionalProperties.properties.nested',
'paths./v1/movies/{movie_id}.get.responses.200.content.application/json.schema.properties.metadata.additionalProperties.properties.nested',
'paths./v1/movies/{movie_id}.put.responses.200.content.application/json.schema.properties.metadata.additionalProperties.properties.nested',
];
for (const i in results) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toMatch(
'Dictionary schemas must have a single, well-defined value type'
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedRulePaths[i]);
}
});
});
});