Skip to content

fix(ibm-valid-schema-example): support circular references in schemas #737

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: 2.x
Choose a base branch
from
Open
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
33 changes: 33 additions & 0 deletions docs/openapi-ruleset-utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,39 @@ for OpenAPI documents where a `required` property is not defined under the `prop

#### Returns `boolean`

### `getResolvedSpec(context)`

Returns the programmatic representation of an OpenAPI document, stored in the
Spectral-created "context" object, with all non-circular references resolved.

#### Parameters

- **`context`** `<object>`: passed as an argument to Spectral-based rule functions

#### Returns `object`: the resolved version of an OpenAPI document

### `getUnresolvedSpec(context)`

Returns the programmatic representation of an OpenAPI document, stored in
the Spectral-created "context" object, with all references still intact.

#### Parameters

- **`context`** `<object>`: passed as an argument to Spectral-based rule functions

#### Returns `object`: the unresolved version of an OpenAPI document

### `getNodes(context)`

Returns the graph nodes, with information about references and the locations
they resolve to, that are computed by the Spectral resolver.

#### Parameters

- **`context`** `<object>`: passed as an argument to Spectral-based rule functions

#### Returns `object`: the graph nodes

### `validateComposedSchemas(schema, path, validate, includeSelf, includeNot)`

Performs validation on a schema and all of its composed schemas.
Expand Down
3 changes: 2 additions & 1 deletion packages/ruleset/src/functions/api-symmetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

const {
getNodes,
getSchemaType,
isObject,
isArraySchema,
Expand Down Expand Up @@ -46,7 +47,7 @@ module.exports = function apiSymmetry(apidef, options, context) {
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}
return checkApiForSymmetry(apidef, context.documentInventory.graph.nodes);
return checkApiForSymmetry(apidef, getNodes(context));
};

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/ruleset/src/functions/collection-array-property.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
schemaHasConstraint,
isArraySchema,
isObject,
getUnresolvedSpec,
} = require('@ibm-cloud/openapi-ruleset-utilities');
const { LoggerFactory } = require('../utils');

Expand All @@ -21,7 +22,7 @@ module.exports = function (schema, _opts, context) {
return collectionArrayProperty(
schema,
context.path,
context.document.parserResult.data
getUnresolvedSpec(context)
);
};

Expand Down
11 changes: 5 additions & 6 deletions packages/ruleset/src/functions/request-and-response-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
* SPDX-License-Identifier: Apache2.0
*/

const { isObject } = require('@ibm-cloud/openapi-ruleset-utilities');
const {
isObject,
getResolvedSpec,
} = require('@ibm-cloud/openapi-ruleset-utilities');
const {
LoggerFactory,
pathHasMinimallyRepresentedResource,
Expand All @@ -29,11 +32,7 @@ module.exports = function requestAndResponseContent(
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}
return checkForContent(
operation,
context.path,
context.documentInventory.resolved
);
return checkForContent(operation, context.path, getResolvedSpec(context));
};

/**
Expand Down
10 changes: 7 additions & 3 deletions packages/ruleset/src/functions/resource-response-consistency.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
*/

const { isEqual } = require('lodash');
const { isObject } = require('@ibm-cloud/openapi-ruleset-utilities');
const {
isObject,
getResolvedSpec,
getNodes,
} = require('@ibm-cloud/openapi-ruleset-utilities');
const {
computeRefsAtPaths,
getResourceSpecificSiblingPath,
Expand All @@ -29,8 +33,8 @@ module.exports = function (operation, _opts, context) {
return resourceResponseConsistency(
operation,
context.path,
context.documentInventory.resolved,
context.documentInventory.graph.nodes
getResolvedSpec(context),
getNodes(context)
);
};

Expand Down
7 changes: 2 additions & 5 deletions packages/ruleset/src/functions/response-status-codes.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* SPDX-License-Identifier: Apache2.0
*/

const { getResolvedSpec } = require('@ibm-cloud/openapi-ruleset-utilities');
const {
LoggerFactory,
isCreateOperation,
Expand All @@ -21,11 +22,7 @@ module.exports = function (operation, _opts, context) {
logger = LoggerFactory.getInstance().getLogger(ruleId);
}

return responseStatusCodes(
operation,
context.path,
context.documentInventory.resolved
);
return responseStatusCodes(operation, context.path, getResolvedSpec(context));
};

/**
Expand Down
7 changes: 5 additions & 2 deletions packages/ruleset/src/functions/schema-naming-convention.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
* SPDX-License-Identifier: Apache2.0
*/

const { schemaHasProperty } = require('@ibm-cloud/openapi-ruleset-utilities');
const {
schemaHasProperty,
getNodes,
} = require('@ibm-cloud/openapi-ruleset-utilities');

const {
LoggerFactory,
Expand Down Expand Up @@ -40,7 +43,7 @@ module.exports = function schemaNames(apidef, options, context) {
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}
return checkSchemaNames(apidef, context.documentInventory.graph.nodes);
return checkSchemaNames(apidef, getNodes(context));
};

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/ruleset/src/functions/use-date-based-format.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
isObject,
isStringSchema,
validateNestedSchemas,
getResolvedSpec,
} = require('@ibm-cloud/openapi-ruleset-utilities');

const {
Expand Down Expand Up @@ -48,7 +49,7 @@ module.exports = function (schema, _opts, context) {
return checkForDateBasedFormat(
schema,
context.path,
context.documentInventory.resolved
getResolvedSpec(context)
);
};

Expand Down
23 changes: 20 additions & 3 deletions packages/ruleset/src/functions/valid-schema-example.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@
*/

const { validate } = require('jsonschema');
const { validateSubschemas } = require('@ibm-cloud/openapi-ruleset-utilities');
const {
validateSubschemas,
getResolvedSpec,
} = require('@ibm-cloud/openapi-ruleset-utilities');
const { LoggerFactory } = require('../utils');

let ruleId;
let logger;
let openapi;

module.exports = function (schema, _opts, context) {
if (!logger) {
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}

openapi = getResolvedSpec(context);

return validateSubschemas(schema, context.path, checkSchemaExamples);
};

Expand Down Expand Up @@ -50,10 +56,21 @@ function checkSchemaExamples(schema, path) {
function validateExamples(examples) {
return examples
.map(({ schema, example, path }) => {
// If the spec includes circular references, there may be unresolved
// references in the schema. The JSON Schema validator needs to be
// able to look those up, so include all of the components in the schema.
const schemaWithComponents = {
...schema,
components: openapi.components,
};

// Setting required: true prevents undefined values from passing validation.
const { valid, errors } = validate(example, schema, { required: true });
const { valid, errors } = validate(example, schemaWithComponents, {
required: true,
});

if (!valid) {
const message = getMessage(errors, example, schema);
const message = getMessage(errors, example, schemaWithComponents);
return {
message: `Schema example is not valid: ${message}`,
path,
Expand Down
70 changes: 70 additions & 0 deletions packages/ruleset/test/rules/valid-schema-example.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@ describe(`Spectral rule: ${ruleId}`, () => {
const results = await testRule(ruleId, rule, rootDocument);
expect(results).toHaveLength(0);
});

it('Schema with valid example contains a circular reference', async () => {
const testDocument = makeCopy(rootDocument);

testDocument.components.schemas.Movie.properties.prop = {
type: 'object',
required: ['some_prop'],
examples: [
{
some_prop: 'example',
other_prop: 'example',
inspiration: {
id: '1234',
name: 'Good Will Hunting',
},
},
],
properties: {
inspiration: {
$ref: '#/components/schemas/Movie',
},
some_prop: {
type: 'string',
},
other_prop: {
type: 'string',
},
},
};

const results = await testRule(ruleId, rule, testDocument);
expect(results).toHaveLength(0);
});
});

describe('Should yield errors', () => {
Expand Down Expand Up @@ -430,5 +463,42 @@ describe(`Spectral rule: ${ruleId}`, () => {
expect(results[i].path.join('.')).toBe(expectedExamplePaths[i]);
}
});

it('Schema contains a circular reference', async () => {
const testDocument = makeCopy(rootDocument);

testDocument.components.schemas.Movie.properties.prop = {
type: 'object',
required: ['some_prop'],
examples: [
{
other_prop: 'example',
},
],
properties: {
inspiration: {
$ref: '#/components/schemas/Movie',
},
some_prop: {
type: 'string',
},
other_prop: {
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).toBe(
`${expectedMsgPrefix} requires property "some_prop"`
);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedExamplesPaths[i]);
}
});
});
});
3 changes: 2 additions & 1 deletion packages/utilities/src/utils/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2017 - 2024 IBM Corporation.
* Copyright 2017 - 2025 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/

Expand All @@ -13,6 +13,7 @@ module.exports = {
schemaHasProperty: require('./schema-has-property'),
schemaLooselyHasConstraint: require('./schema-loosely-has-constraint'),
schemaRequiresProperty: require('./schema-requires-property'),
...require('./spectral-context-utils'),
validateComposedSchemas: require('./validate-composed-schemas'),
validateNestedSchemas: require('./validate-nested-schemas'),
validateSubschemas: require('./validate-subschemas'),
Expand Down
40 changes: 40 additions & 0 deletions packages/utilities/src/utils/spectral-context-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Copyright 2025 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/

/**
* Returns the programmatic representation of an OpenAPI document, stored in the
* Spectral-created "context" object, with all non-circular references resolved.
* @param {object} context passed as an argument to Spectral-based rule functions
* @returns {object} the resolved version of an OpenAPI document
*/
function getResolvedSpec(context) {
return context.documentInventory.resolved;
}

/**
* Returns the programmatic representation of an OpenAPI document, stored in
* the Spectral-created "context" object, with all references still intact.
* @param {object} context passed as an argument to Spectral-based rule functions
* @returns {object} the unresolved version of an OpenAPI document
*/
function getUnresolvedSpec(context) {
return context.document.parserResult.data;
}

/**
* Returns the graph nodes, with information about references and the locations
* they resolve to, that are computed by the Spectral resolver.
* @param {object} context passed as an argument to Spectral-based rule functions
* @returns {object} the graph nodes
*/
function getNodes(context) {
return context.documentInventory.graph.nodes;
}

module.exports = {
getNodes,
getResolvedSpec,
getUnresolvedSpec,
};
Loading
Loading