diff --git a/packages/ruleset/src/functions/valid-schema-example.js b/packages/ruleset/src/functions/valid-schema-example.js index 36c5a273..eb53d3e6 100644 --- a/packages/ruleset/src/functions/valid-schema-example.js +++ b/packages/ruleset/src/functions/valid-schema-example.js @@ -5,7 +5,7 @@ const { validate } = require('jsonschema'); const { validateSubschemas } = require('@ibm-cloud/openapi-ruleset-utilities'); -const { LoggerFactory } = require('../utils'); +const { nestedSchemaKeys, LoggerFactory } = require('../utils'); let ruleId; let logger; @@ -50,6 +50,14 @@ function checkSchemaExamples(schema, path) { function validateExamples(examples) { return examples .map(({ schema, example, path }) => { + if (hasUnresolvedRefs(schema)) { + logger.debug( + `Skipping example validation at path ${path.join('.')}: schema contains unresolved $ref references` + ); + // Skip validation for schemas with unresolved references. + return undefined; + } + // Setting required: true prevents undefined values from passing validation. const { valid, errors } = validate(example, schema, { required: true }); if (!valid) { @@ -63,6 +71,45 @@ function validateExamples(examples) { .filter(e => isDefined(e)); } +/** + * Recursively checks if a schema or any of its nested schemas contain unresolved $ref references. + * @param {object} schema - The schema to check + * @returns {boolean} - True if the schema contains unresolved $ref references + */ +function hasUnresolvedRefs(schema) { + if (!schema || typeof schema !== 'object') { + return false; + } + + if (schema.$ref) { + return true; + } + + // Recursively check nested schemas in common locations. + for (const key of nestedSchemaKeys) { + if (schema[key]) { + if (Array.isArray(schema[key])) { + // Check each item in arrays (allOf, anyOf, oneOf). + if (schema[key].some(item => hasUnresolvedRefs(item))) { + return true; + } + } else if (key === 'properties') { + // Check each property in properties object. + if (Object.values(schema[key]).some(prop => hasUnresolvedRefs(prop))) { + return true; + } + } else { + // Check single nested schema (items, additionalProperties, not). + if (hasUnresolvedRefs(schema[key])) { + return true; + } + } + } + } + + return false; +} + function isDefined(x) { return x !== undefined; } diff --git a/packages/ruleset/src/utils/index.js b/packages/ruleset/src/utils/index.js index 5ea3ea17..fd8b0447 100644 --- a/packages/ruleset/src/utils/index.js +++ b/packages/ruleset/src/utils/index.js @@ -18,6 +18,7 @@ module.exports = { isRequestBodyExploded: require('./is-requestbody-exploded'), LoggerFactory: require('./logger-factory'), mergeAllOfSchemaProperties: require('./merge-allof-schema-properties'), + nestedSchemaKeys: require('./nested-schema-keys'), operationMethods: require('./constants'), pathHasMinimallyRepresentedResource: require('./path-has-minimally-represented-resource'), pathMatchesRegexp: require('./path-matches-regexp'), diff --git a/packages/ruleset/src/utils/nested-schema-keys.js b/packages/ruleset/src/utils/nested-schema-keys.js new file mode 100644 index 00000000..3ef61583 --- /dev/null +++ b/packages/ruleset/src/utils/nested-schema-keys.js @@ -0,0 +1,16 @@ +/** + * Copyright 2017 - 2026 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +const nestedSchemaKeys = [ + 'items', + 'additionalProperties', + 'properties', + 'allOf', + 'anyOf', + 'oneOf', + 'not', +]; + +module.exports = nestedSchemaKeys; diff --git a/packages/ruleset/test/rules/valid-schema-example.test.js b/packages/ruleset/test/rules/valid-schema-example.test.js index 089252be..4c1ca4ff 100644 --- a/packages/ruleset/test/rules/valid-schema-example.test.js +++ b/packages/ruleset/test/rules/valid-schema-example.test.js @@ -22,6 +22,125 @@ describe(`Spectral rule: ${ruleId}`, () => { 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', () => {