Skip to content

Commit 29ef3c7

Browse files
authored
fix(ibm-api-symmetry): handle dependencies between canonical and reference schemas (#733)
Previously, if a canonical schema contained a reference to its corresponding reference schema, it would create an infinite cycle when the rule tried to resolve a reference schema into its corresponding canonical schema. This commit adds logic to detect that scenario and prevent the cycle. Signed-off-by: Dustin Popp <[email protected]>
1 parent f01f04c commit 29ef3c7

File tree

2 files changed

+91
-7
lines changed

2 files changed

+91
-7
lines changed

packages/ruleset/src/functions/api-symmetry.js

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77
getSchemaType,
88
isObject,
99
isArraySchema,
10+
validateSubschemas,
1011
} = require('@ibm-cloud/openapi-ruleset-utilities');
1112

1213
const {
@@ -403,16 +404,33 @@ function canonicalSchemaMeetsConstraint(
403404
// If we find the canonical schema, use it for the current function invocation,
404405
// otherwise proceed as usual.
405406
if (
406-
schemaName &&
407-
schemaName.endsWith('Reference') &&
407+
schemaName?.endsWith('Reference') &&
408408
schemaFinder.allSchemas[schemaName.slice(0, -9)]
409409
) {
410410
const canonicalVersion = schemaName.slice(0, -9);
411-
logger.debug(
412-
`replacing reference schema ${schemaName} with canonical version ${canonicalVersion}`
413-
);
414-
schema = schemaFinder.allSchemas[canonicalVersion];
415-
path = ['components', 'schemas', canonicalVersion];
411+
const canonicalSchema = schemaFinder.allSchemas[canonicalVersion];
412+
const canonicalPath = ['components', 'schemas', canonicalVersion];
413+
414+
if (
415+
canonicalIncludesReference(
416+
canonicalSchema,
417+
canonicalPath,
418+
schemaName,
419+
schemaFinder
420+
)
421+
) {
422+
logger.info(
423+
`Canonical schema ${canonicalVersion} contains a nested reference to ${
424+
schemaName
425+
}, so it will not be used as a replacement. This may produce unintended behavior.`
426+
);
427+
} else {
428+
logger.debug(
429+
`replacing reference schema ${schemaName} with canonical version ${canonicalVersion}`
430+
);
431+
schema = canonicalSchema;
432+
path = canonicalPath;
433+
}
416434
}
417435

418436
if (hasConstraint(schema, path)) {
@@ -440,3 +458,26 @@ function canonicalSchemaMeetsConstraint(
440458

441459
return false;
442460
}
461+
462+
// If a canonical schemas includes a nested reference to its corresponding
463+
// reference schema, the reference resolution logic will infinitely loop.
464+
// This is a helper function for detecting that scenario ahead of time.
465+
function canonicalIncludesReference(
466+
schema,
467+
path,
468+
referenceSchemaName,
469+
schemaFinder
470+
) {
471+
// This utilizes the robust logic in validateSubschemas for recursively
472+
// looking through schemas, albeit in an unconventional way. The callback
473+
// must return an array, but we don't care about returning information here,
474+
// so we treat the presence of any value in the array as a "true" result.
475+
const instances = validateSubschemas(schema, path, (_, p) =>
476+
getSchemaNameAtPath(p.join('.'), schemaFinder.refMap) ===
477+
referenceSchemaName
478+
? [true]
479+
: []
480+
);
481+
482+
return !!instances.length;
483+
}

packages/ruleset/test/rules/api-symmetry.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,49 @@ describe(`Spectral rule: ${ruleId}`, () => {
809809
expect(results).toHaveLength(0);
810810
});
811811

812+
it('canonical schema contains nested reference to reference schema', async () => {
813+
const testDocument = makeCopy(rootDocument);
814+
815+
testDocument.components.schemas.Actor = {
816+
type: 'object',
817+
allOf: [
818+
{ $ref: '#/components/schemas/ActorReference' },
819+
{
820+
properties: {
821+
name: { type: 'string' },
822+
gender: { type: 'string' },
823+
age: { type: 'integer' },
824+
},
825+
},
826+
],
827+
};
828+
testDocument.components.schemas.ActorReference = {
829+
type: 'object',
830+
properties: {
831+
id: { type: 'string' },
832+
},
833+
};
834+
testDocument.components.schemas.ActorPrototype = {
835+
type: 'object',
836+
properties: {
837+
name: { type: 'string' },
838+
gender: { type: 'string' },
839+
age: { type: 'integer' },
840+
},
841+
};
842+
843+
testDocument.components.schemas.Movie.properties.lead_role = {
844+
$ref: '#/components/schemas/Actor',
845+
};
846+
847+
testDocument.components.schemas.MoviePrototype.properties.lead_role = {
848+
$ref: '#/components/schemas/ActorPrototype',
849+
};
850+
851+
const results = await testRule(ruleId, rule, testDocument);
852+
expect(results).toHaveLength(0);
853+
});
854+
812855
// Already covered in root document:
813856
// - Valid Prototype schemas
814857
// - Valid Patch schemas

0 commit comments

Comments
 (0)