@ibm-cloud/openapi-ruleset
Version:
Spectral ruleset for validating IBM Cloud services
446 lines (374 loc) • 14.6 kB
JavaScript
/**
* Copyright 2023 - 2024 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/
const { resourceResponseConsistency } = require('../../src/rules');
const {
makeCopy,
rootDocument,
testRule,
severityCodes,
} = require('../test-utils');
const rule = resourceResponseConsistency;
const ruleId = 'ibm-resource-response-consistency';
const expectedSeverity = severityCodes.warning;
const expectedMsgSingle =
'Operations on a single resource instance should return the resource canonical schema';
const expectedMsgBulk =
'Bulk resource operations should return the resource collection schema';
describe(`Spectral rule: ${ruleId}`, () => {
describe('Should not yield errors', () => {
it('Clean spec', async () => {
const results = await testRule(ruleId, rule, rootDocument);
expect(results).toHaveLength(0);
});
it('202 create response does not need to match canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
delete testDocument.paths['/v1/drinks'].post.responses['201'];
testDocument.paths['/v1/drinks'].post.responses['202'] = {
description: 'Asynchronous success!',
content: {
'application/json': {
schema: {
type: 'object',
description: 'object containing status of creation operation',
properties: {
status: {
type: 'string',
description: 'current state of resource',
enum: ['pending', 'failed', 'created'],
},
},
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('create operation defines no responses', async () => {
const testDocument = makeCopy(rootDocument);
delete testDocument.paths['/v1/drinks'].post.responses;
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('create operation defines no success codes', async () => {
const testDocument = makeCopy(rootDocument);
delete testDocument.paths['/v1/drinks'].post.responses['201'];
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('create operation defines no success response content', async () => {
const testDocument = makeCopy(rootDocument);
delete testDocument.paths['/v1/drinks'].post.responses['201'].content;
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('create operation defines no JSON response content', async () => {
const testDocument = makeCopy(rootDocument);
delete testDocument.paths['/v1/drinks'].post.responses['201'].content[
'application/json'
];
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('create operation defines no schema for JSON response content', async () => {
const testDocument = makeCopy(rootDocument);
delete testDocument.paths['/v1/drinks'].post.responses['201'].content[
'application/json'
].schema;
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('PUT request on generic, non-collection path returns canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/drinks/{id}/recipe'] = {
put: {
operationId: 'replace_drink_recipe',
responses: {
200: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Recipe',
},
},
},
},
},
},
get: {
operationId: 'get_drink_recipe',
responses: {
200: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Recipe',
},
},
},
},
},
},
};
testDocument.components.schemas.Recipe = {
type: 'object',
description: 'A recipe resource to bind to a drink resource',
properties: {
ingredients: {
type: 'array',
items: {
type: 'string',
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
it('PUT request on collection path returns collection schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/drinks'] = {
put: {
operationId: 'replace_drinks',
responses: {
200: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/DrinkCollection',
},
},
},
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
});
describe('Should yield errors', () => {
it('200 create request that does not return canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
// convert 201 response into 200
testDocument.paths['/v1/drinks'].post.responses['200'] =
testDocument.paths['/v1/drinks'].post.responses['201'];
delete testDocument.paths['/v1/drinks'].post.responses['201'];
// redo the response schema
testDocument.paths['/v1/drinks'].post.responses['200'].content[
'application/json'
].schema.$ref = '#/components/schemas/Car';
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgSingle}: Drink`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/drinks.post.responses.200.content.application/json'
);
});
it('201 create request that does not return canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/drinks'].post.responses['201'].content[
'application/json'
].schema.$ref = '#/components/schemas/Car';
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgSingle}: Drink`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/drinks.post.responses.201.content.application/json'
);
});
it('warns for all JSON content schemas on create that do not match canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/drinks'].post.responses['201'].content[
'application/json'
].schema.$ref = '#/components/schemas/Car';
testDocument.paths['/v1/drinks'].post.responses['201'].content[
'application/json; charset=utf-8'
] = {
schema: {
$ref: '#/components/schemas/Car',
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(2);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgSingle}: Drink`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/drinks.post.responses.201.content.application/json'
);
expect(results[1].code).toBe(ruleId);
expect(results[1].message).toBe(`${expectedMsgSingle}: Drink`);
expect(results[1].severity).toBe(expectedSeverity);
expect(results[1].path.join('.')).toBe(
'paths./v1/drinks.post.responses.201.content.application/json; charset=utf-8'
);
});
it('warns for both 200 and 201 responses on create that do not match the canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/drinks'].post.responses['200'] = {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Car',
},
},
},
};
testDocument.paths['/v1/drinks'].post.responses['201'].content[
'application/json'
].schema.$ref = '#/components/schemas/Car';
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(2);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgSingle}: Drink`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/drinks.post.responses.200.content.application/json'
);
expect(results[1].code).toBe(ruleId);
expect(results[1].message).toBe(`${expectedMsgSingle}: Drink`);
expect(results[1].severity).toBe(expectedSeverity);
expect(results[1].path.join('.')).toBe(
'paths./v1/drinks.post.responses.201.content.application/json'
);
});
it('200 PUT request that does not return canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/movies/{movie_id}'].put.responses['200'] = {
content: {
'application/json; charset=utf-8': {
schema: {
$ref: '#/components/schemas/Car',
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgSingle}: Movie`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/movies/{movie_id}.put.responses.200.content.application/json; charset=utf-8'
);
});
it('200 PATCH request that does not return canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/cars/{car_id}'].patch.responses['200'].content[
'application/json'
].schema.$ref = '#/components/schemas/Movie';
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgSingle}: Car`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/cars/{car_id}.patch.responses.200.content.application/json'
);
});
it('Specific JSON content on GET request is found for comparison', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/cars/{car_id}'].patch.responses['200'].content[
'application/json'
].schema.$ref = '#/components/schemas/Movie';
testDocument.paths['/v1/cars/{car_id}'].get.responses['200'].content = {
'application/json; charset=utf-8': {
schema: '#/components/schemas/Car',
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgSingle}: Car`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/cars/{car_id}.patch.responses.200.content.application/json'
);
});
it('PUT request on generic, non-collection path does not return canonical schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/drinks/{id}/recipe'] = {
put: {
operationId: 'replace_drink_recipe',
responses: {
200: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Drink',
},
},
},
},
},
},
get: {
operationId: 'get_drink_recipe',
responses: {
200: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Recipe',
},
},
},
},
},
},
};
testDocument.components.schemas.Recipe = {
type: 'object',
description: 'A recipe resource to bind to a drink resource',
properties: {
ingredients: {
type: 'array',
items: {
type: 'string',
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgSingle}: Recipe`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/drinks/{id}/recipe.put.responses.200.content.application/json'
);
});
it('PUT request on collection path does not return collection schema', async () => {
const testDocument = makeCopy(rootDocument);
testDocument.paths['/v1/drinks'].put = {
operationId: 'replace_drinks',
responses: {
200: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Drink',
},
},
},
},
},
};
const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(1);
expect(results[0].code).toBe(ruleId);
expect(results[0].message).toBe(`${expectedMsgBulk}: DrinkCollection`);
expect(results[0].severity).toBe(expectedSeverity);
expect(results[0].path.join('.')).toBe(
'paths./v1/drinks.put.responses.200.content.application/json'
);
});
});
});