Skip to content

Commit 7498e49

Browse files
committed
fix(ibm-api-symmetry): handle dictionaries and top-level schemas
This rule was subject to both false positives, as it flagged any schema with a dictionary property (even when inappropriate), and false negatives, as it did not check top-level schemas, only schema properties. This fix resolves both issues and cleans up the rule logic on the whole. Signed-off-by: Dustin Popp <[email protected]>
1 parent abcc92c commit 7498e49

File tree

2 files changed

+308
-74
lines changed

2 files changed

+308
-74
lines changed

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

Lines changed: 109 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
/**
2-
* Copyright 2023 - 2024 IBM Corporation.
2+
* Copyright 2023 - 2025 IBM Corporation.
33
* SPDX-License-Identifier: Apache2.0
44
*/
55

6-
const { isEqual } = require('lodash');
7-
86
const {
97
getSchemaType,
108
isObject,
11-
isObjectSchema,
129
isArraySchema,
1310
schemaHasConstraint,
1411
schemaLooselyHasConstraint,
@@ -91,7 +88,7 @@ function checkApiForSymmetry(apidef, nodes) {
9188
['Summary', 'Prototype', 'Patch'].forEach(variantType => {
9289
const variantSchemaName = `${canonicalSchemaName}${variantType}`;
9390
const variantSchema = apidef.components.schemas[`${variantSchemaName}`];
94-
if (variantSchema && isObjectSchema(variantSchema)) {
91+
if (variantSchema && isObject(variantSchema)) {
9592
logger.info(
9693
`${ruleId}: checking variant schema ${variantSchemaName} against canonical schema ${canonicalSchemaName}`
9794
);
@@ -166,109 +163,148 @@ function checkForGraphFragmentPattern(
166163
);
167164
let result = true;
168165

169-
// A variant schema cannot allow extraneous properties that aren't allowed
170-
// on the canonical schema and still be a graph fragment.
166+
// Check for simple type equivalency - if the types are not the same,
167+
// the graph fragment pattern is violated.
168+
if (
169+
!fromApplicator &&
170+
!canonicalSchemaMeetsConstraint(
171+
canonical,
172+
canonicalPath,
173+
schemaFinder,
174+
schema => getSchemaType(variant) === getSchemaType(schema)
175+
)
176+
) {
177+
logger.info(
178+
`${ruleId}: variant and canonical schemas are different types`
179+
);
180+
result = false;
181+
}
182+
183+
// Ensure list schemas also maintain a graph fragment structure.
184+
if (
185+
isObject(variant.items) &&
186+
isArraySchema(variant) &&
187+
!canonicalSchemaMeetsConstraint(
188+
canonical,
189+
canonicalPath,
190+
schemaFinder,
191+
(schema, path) =>
192+
isObject(schema.items) &&
193+
isGraphFragment(
194+
variant.items,
195+
schema.items,
196+
[...path, 'items'],
197+
false
198+
)
199+
)
200+
) {
201+
logger.info(
202+
`${ruleId}: variant is array with schema that is not a graph fragment of canonical items schema`
203+
);
204+
result = false;
205+
}
206+
207+
// Ensure dictionary schemas also maintain a graph fragment structure
208+
// (additional properties).
171209
if (
172210
variant.additionalProperties &&
173-
!isEqual(variant.additionalProperties, canonical.additionalProperties)
211+
!canonicalSchemaMeetsConstraint(
212+
canonical,
213+
canonicalPath,
214+
schemaFinder,
215+
(schema, path) =>
216+
schema.additionalProperties &&
217+
isGraphFragment(
218+
variant.additionalProperties,
219+
schema.additionalProperties,
220+
[...path, 'additionalProperties'],
221+
false
222+
)
223+
)
174224
) {
175225
logger.info(
176-
`${ruleId}: 'additionalProperties' on variant does not match canonical - it is not a proper graph fragment`
226+
`${ruleId}: variant is dictionary with an additionalProperties schema that is not a graph fragment of canonical`
177227
);
178228
result = false;
179229
}
230+
231+
// Ensure dictionary schemas also maintain a graph fragment structure
232+
// (pattern properties).
180233
if (
181-
variant.patternProperties &&
182-
!isEqual(variant.patternProperties, canonical.patternProperties)
234+
isObject(variant.patternProperties) &&
235+
!canonicalSchemaMeetsConstraint(
236+
canonical,
237+
canonicalPath,
238+
schemaFinder,
239+
(schema, path) =>
240+
isObject(schema.patternProperties) &&
241+
// This is a little convoluted but it is enforcing that
242+
// 1) every pattern in the variant schema is also in canonical
243+
// schema, and
244+
// 2) every patterned schema in the variant must be a graph fragment
245+
// of at least one patterned schema in the canonical schema.
246+
Object.entries(variant.patternProperties).every(
247+
([variantPattern, variantPatternSchema]) =>
248+
Object.keys(schema.patternProperties).includes(variantPattern) &&
249+
Object.entries(schema.patternProperties).some(
250+
([canonPattern, canonPatternSchema]) =>
251+
isGraphFragment(
252+
variantPatternSchema,
253+
canonPatternSchema,
254+
[...path, 'patternProperties', canonPattern],
255+
false
256+
)
257+
)
258+
)
259+
)
183260
) {
184261
logger.info(
185-
`${ruleId}: 'patternProperties' on variant does not match canonical - it is not a proper graph fragment`
262+
`${ruleId}: variant is dictionary with a patternProperties schema that is not a graph fragment of canonical`
186263
);
187264
result = false;
188265
}
189266

190267
// If the variant schema (or sub-schema) has properties, ensure that each
191268
// property is defined *somewhere* on the corresponding canonical schema
192-
// (or sub-schema) and has the same, specific type.
269+
// (or sub-schema) and ensure it is also a valid graph fragment of the
270+
// corresponding property in the canonical schema.
193271
//
194-
// We use a custom, looser contraint-checking function here because it is
272+
// We use a looser contraint-checking function here because it is
195273
// sufficient for "one of" or "any of" the canonical schemas to define the
196274
// property defined on the variant schema, and we need to resolve reference
197275
// schemas on the fly each time we check the canonical schema for a constraint.
198276
if (isObject(variant.properties)) {
199277
for (const [name, prop] of Object.entries(variant.properties)) {
278+
let propExistsSomewhere = false;
279+
200280
const valid = canonicalSchemaMeetsConstraint(
201281
canonical,
202282
canonicalPath,
203283
schemaFinder,
204-
c =>
205-
'properties' in c &&
206-
isObject(c.properties[name]) &&
207-
getSchemaType(c.properties[name]) === getSchemaType(prop)
208-
);
284+
(schema, path) => {
285+
const exists =
286+
'properties' in schema && isObject(schema.properties[name]);
287+
propExistsSomewhere = propExistsSomewhere || exists;
209288

210-
// Note: Prototype schemas are allowed to define writeOnly properties
211-
// that don't exist on the canonical schema.
212-
if (!valid && !(considerWriteOnly && prop.writeOnly)) {
213-
logger.info(
214-
`${ruleId}: property '${name}' does not exist on the canonical schema`
215-
);
216-
result = false;
217-
}
218-
219-
// Ensure nested schemas are also graph fragments of the corresponding
220-
// nested schemas in the canonical schema.
221-
if (
222-
valid &&
223-
isObjectSchema(prop) &&
224-
!canonicalSchemaMeetsConstraint(
225-
canonical,
226-
canonicalPath,
227-
schemaFinder,
228-
(schema, path) =>
229-
// Note that these first two conditions are guaranteed to be met at
230-
// least once by the first call to `canonicalSchemaMeetsConstraint`
231-
'properties' in schema &&
232-
isObject(schema.properties[name]) &&
289+
return (
290+
exists &&
233291
isGraphFragment(
234292
prop,
235293
schema.properties[name],
236294
[...path, 'properties', name],
237295
false
238296
)
239-
)
240-
) {
241-
logger.info(
242-
`${ruleId}: nested object property ${name} is not a graph fragment of canonical property ${name}`
243-
);
244-
result = false;
245-
}
297+
);
298+
}
299+
);
246300

247-
// Ensure lists of schemas also maintain a graph fragment structure.
248-
if (
249-
valid &&
250-
isArraySchema(prop) &&
251-
isObjectSchema(prop.items) &&
252-
!canonicalSchemaMeetsConstraint(
253-
canonical,
254-
canonicalPath,
255-
schemaFinder,
256-
(schema, path) =>
257-
// Note that these first two conditions are guaranteed to be met at
258-
// least once by the first call to `canonicalSchemaMeetsConstraint`
259-
'properties' in schema &&
260-
isObject(schema.properties[name]) &&
261-
isObject(schema.properties[name].items) &&
262-
isGraphFragment(
263-
prop.items,
264-
schema.properties[name].items,
265-
[...path, 'properties', name, 'items'],
266-
false
267-
)
268-
)
269-
) {
301+
// Note: Prototype schemas are allowed to define writeOnly properties
302+
// that don't exist on the canonical schema.
303+
if (!valid && !(considerWriteOnly && prop.writeOnly)) {
270304
logger.info(
271-
`${ruleId}: array property ${name} items schema is not a graph fragment of canonical property ${name} items schema`
305+
propExistsSomewhere
306+
? `${ruleId}: nested object property ${name} is not a graph fragment of canonical property ${name}`
307+
: `${ruleId}: property '${name}' does not exist on the canonical schema`
272308
);
273309
result = false;
274310
}

0 commit comments

Comments
 (0)