UNPKG

@ibm-cloud/openapi-ruleset

Version:

Spectral ruleset for validating IBM Cloud services

554 lines (489 loc) 16.8 kB
/** * Copyright 2025 IBM Corporation. * SPDX-License-Identifier: Apache2.0 */ const { validSchemaExample } = require('../../src/rules'); const { makeCopy, rootDocument, testRule, severityCodes, } = require('../test-utils'); const rule = validSchemaExample; const ruleId = 'ibm-valid-schema-example'; const expectedSeverity = severityCodes.warning; const expectedMsgPrefix = 'Schema example is not valid:'; 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('Recursive schema should not make the rule fail', async () => { const testDocument = makeCopy(rootDocument); // Replace the API structure with the recursiveAPI structure testDocument.paths = { '/test': { get: { description: 'Test', operationId: 'listTest', parameters: [], responses: { 200: { description: 'Paginated list of objects', content: { 'application/json': { schema: { $ref: '#/components/schemas/TestListResponse', }, }, }, }, }, security: [ { bearer: [], }, ], summary: 'List test', tags: ['Test'], }, }, }; testDocument.tags = [ { name: 'Test', }, ]; testDocument.components.securitySchemes = { bearer: { scheme: 'bearer', bearerFormat: 'JWT', type: 'http', }, }; testDocument.components.schemas = { Templates: { type: 'object', properties: { id: { type: 'number', example: 1234, description: 'Id', }, templates: { description: 'Nested check templates', type: 'array', items: { $ref: '#/components/schemas/Templates', }, }, }, required: ['id'], }, TestResponse: { type: 'object', properties: { id: { type: 'number', example: 12345, description: 'test id', }, templates: { description: 'templates', example: [ { id: 2, templates: [ { id: 3, }, { id: 4, }, ], }, ], type: 'array', items: { $ref: '#/components/schemas/Templates', }, }, }, required: ['id', 'templates'], }, TestListResponse: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/TestResponse', }, }, }, required: ['data'], }, }; // Remove responses and requestBodies that reference old schemas delete testDocument.components.responses; delete testDocument.components.requestBodies; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(0); }); }); describe('Should yield errors', () => { const expectedExamplePaths = [ 'paths./v1/movies.get.responses.200.content.application/json.schema.allOf.1.properties.movies.items.properties.prop.example', 'paths./v1/movies.post.responses.201.content.application/json.schema.properties.prop.example', 'paths./v1/movies/{movie_id}.get.responses.200.content.application/json.schema.properties.prop.example', 'paths./v1/movies/{movie_id}.put.responses.200.content.application/json.schema.properties.prop.example', ]; const expectedExamplesPaths = [ 'paths./v1/movies.get.responses.200.content.application/json.schema.allOf.1.properties.movies.items.properties.prop.examples.0', 'paths./v1/movies.post.responses.201.content.application/json.schema.properties.prop.examples.0', 'paths./v1/movies/{movie_id}.get.responses.200.content.application/json.schema.properties.prop.examples.0', 'paths./v1/movies/{movie_id}.put.responses.200.content.application/json.schema.properties.prop.examples.0', ]; it('Schema has undefined example in examples array', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'string', examples: [undefined], }; 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).toBe( `${expectedMsgPrefix} is not of a type(s) string` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplesPaths[i]); } }); it('String schema has non-string example', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'string', example: 42, }; 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).toBe( `${expectedMsgPrefix} is not of a type(s) string` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('String schema has pattern-violating example in examples array', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'string', pattern: '^ibm-.+$', examples: ['bad example', 'ibm-good-example'], }; 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).toBe( `${expectedMsgPrefix} does not match pattern "^ibm-.+$"` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplesPaths[i]); } }); it('String schema has minLength-violating example', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'string', minLength: 4, example: 'abc', }; 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).toBe( `${expectedMsgPrefix} does not meet minimum length of 4` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Number schema has non-number example', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'integer', example: '123', }; 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).toBe( `${expectedMsgPrefix} is not of a type(s) integer` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Number schema has maximum-violating number example', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'integer', maximum: 100, example: 9000, }; 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).toBe( `${expectedMsgPrefix} must be less than or equal to 100` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Array schema has example violating nested object schema', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'array', items: { type: 'object', properties: { metadata: { type: 'string', }, }, }, example: [{ metadata: 42 }], }; 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).toBe( `${expectedMsgPrefix} 0.metadata is not of a type(s) string` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Object schema has example without required property', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'object', required: ['id'], properties: { id: { type: 'string', }, name: { type: 'string', }, }, example: { name: 'gollum' }, }; 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).toBe( `${expectedMsgPrefix} requires property "id"` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Object schema has example violating deeply nested object schema', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'object', properties: { other_prop: { type: 'object', properties: { and_another_prop: { type: 'boolean', }, }, }, }, example: { other_prop: { and_another_prop: 42 } }, }; 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).toBe( `${expectedMsgPrefix} other_prop.and_another_prop is not of a type(s) boolean` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Object schema has example that does not match oneOf', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'object', oneOf: [ { title: 'A', type: 'object', properties: { other_prop: { type: 'string', }, }, additionalProperties: false, }, { title: 'B', type: 'object', properties: { another_prop: { type: 'string', }, }, additionalProperties: false, }, ], properties: { main_prop: { type: 'string', }, }, example: { main_prop: 'value', other_prop: 'other value' }, }; 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).toBe( `${expectedMsgPrefix} is not exactly one from "A","B" (is not allowed to have the additional property "main_prop")` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Object schema has example that does not match anyOf', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'object', anyOf: [ { title: 'A', type: 'object', properties: { other_prop: { type: 'string', }, }, additionalProperties: false, }, { title: 'B', type: 'object', properties: { another_prop: { type: 'string', }, }, additionalProperties: false, }, ], example: { other_prop: 42 }, }; 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).toBe( `${expectedMsgPrefix} is not any of "A","B" (other_prop is not of a type(s) string)` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Object allOf schema has primitive example', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'object', allOf: [ { title: 'A', type: 'object', properties: { other_prop: { type: 'string', }, }, }, { title: 'B', type: 'object', properties: { another_prop: { type: 'string', }, }, }, ], example: 'my-props.json', }; 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).toBe( `${expectedMsgPrefix} is not of a type(s) object` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); it('Object allOf schema has violating example', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.schemas.Movie.properties.prop = { type: 'object', allOf: [ { title: 'A', type: 'object', properties: { other_prop: { type: 'string', }, }, }, { title: 'B', type: 'object', properties: { another_prop: { type: 'string', }, }, }, ], example: { other_prop: 42 }, }; 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).toBe( `${expectedMsgPrefix} does not match allOf schema "A" (other_prop is not of a type(s) string)` ); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]); } }); }); });