diff --git a/docs/ibm-cloud-rules.md b/docs/ibm-cloud-rules.md
index c4c892268..0053c068b 100644
--- a/docs/ibm-cloud-rules.md
+++ b/docs/ibm-cloud-rules.md
@@ -37,6 +37,7 @@ which is delivered in the `@ibm-cloud/openapi-ruleset` NPM package.
* [Rule: binary-schemas](#rule-binary-schemas)
* [Rule: circular-refs](#rule-circular-refs)
* [Rule: collection-array-property](#rule-collection-array-property)
+ * [Rule: composed-schema-restrictions](#rule-composed-schema-restrictions)
* [Rule: consecutive-path-param-segments](#rule-consecutive-path-param-segments)
* [Rule: content-entry-contains-schema](#rule-content-entry-contains-schema)
* [Rule: content-entry-provided](#rule-content-entry-provided)
@@ -178,6 +179,13 @@ within the operation's path string, which should also match the plural form of t
consecutive-path-param-segments |
error |
Checks each path string in the API definition to detect the presence of two or more consecutive
@@ -1321,6 +1329,91 @@ paths:
+### Rule: composed-schema-restrictions
+
+
+Rule id: |
+composed-schema-restrictions |
+
+
+Description: |
+This rule examines each schema that includes a oneOf/anyOf composition and verifies that
+it complies with SDK generation best practices.
+ These best practices include the following restrictions:
+
+- Any object schema containing a oneOf/anyOf composition must include only object schemas within
+the oneOf/anyOf list.
+- The union of the properties defined by the main schema and its
+oneOf/anyOf sub-schemas is examined to detect any like-named (common) properties defined by two or more of the schemas.
+These common properties must be defined with the same datatype.
+
+ |
+
+
+Severity: |
+warn |
+
+
+OAS Versions: |
+oas3 |
+
+
+Non-compliant example: |
+
+
+components:
+ schemas:
+ Foo:
+ description: A schema that violates the restrictions
+ type: object
+ oneOf:
+ - $ref: '#/components/schemas/SubSchema1
+ - $ref: '#/components/schemas/SubSchema2
+ SubSchema1:
+ properties:
+ common_property:
+ type: string
+ another_property:
+ type: integer
+ SubSchema2:
+ properties:
+ common_property:
+ type: integer <<< incompatible type
+ some_other_property:
+ type: integer
+
+ |
+
+
+Compliant example: |
+
+
+components:
+ schemas:
+ Foo:
+ description: A schema that complies with the restrictions
+ type: object
+ oneOf:
+ - $ref: '#/components/schemas/SubSchema1
+ - $ref: '#/components/schemas/SubSchema2
+ SubSchema1:
+ properties:
+ common_property:
+ type: string
+ another_property:
+ type: string
+ SubSchema2:
+ properties:
+ common_property:
+ type: string
+ some_other_property:
+ type: integer
+
+ |
+
+
+
+
### Rule: consecutive-path-param-segments
@@ -2245,12 +2338,12 @@ it is a best practice to define the schema as a named schema within the co
of the API definition, and then reference it with a schema $ref instead of defining it as an inline object schema.
This is documented in the
[API Handbook](https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-schemas#nested-object-schemas).
-The use of a schema $ref is preferred instead of a nested object schema, because the SDK generator will
+ The use of a schema $ref is preferred instead of a nested object schema, because IBM's SDK generator will
use the schema $ref when determining the datatype associated with the nested object within the generated SDK code.
-If the SDK generator encounters a nested objet schema, it must refactor it by moving it to the components.schemas
+If IBM's SDK generator encounters a nested object schema, it must refactor it by moving it to the components.schemas
section of the API definition and then replacing it with a schema $ref.
-However, the names computed by the SDK generator are not optimal (e.g. MyModelProp1),
-so the recommendation is to define any nested object schema as a $ref so that the SDK generator's
+However, the names computed by the generator are not optimal (e.g. MyModelProp1),
+so the recommendation is to define any nested object schema as a $ref so that the generator's
refactoring (and it's sub-optimal name computation) can be avoided.
@@ -2404,10 +2497,10 @@ paths:
Description: |
A response schema should be defined as a reference to a named schema instead of defined as an inline schema.
-This is a best practice because the SDK generator will use the schema reference when determining the operation's return type
+This is a best practice because IBM's SDK generator will use the schema reference when determining the operation's return type
within the generated SDK code.
- The SDK generator will refactor any inline response schemas that it finds by moving them to the components.schemas
-section of the API definition and then replacing them with a reference. However, the names computed by the SDK generator are
+ IBM's SDK generator will refactor any inline response schemas that it finds by moving them to the components.schemas
+section of the API definition and then replacing them with a reference. However, the names computed by the generator are
not optimal (e.g. GetThingResponse), so the recommendation is for API authors to define the response schema as a $ref.
|
diff --git a/package-lock.json b/package-lock.json
index 5e4d62645..5d2fb24a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12924,12 +12924,13 @@
},
"packages/ruleset": {
"name": "@ibm-cloud/openapi-ruleset",
- "version": "0.43.0",
+ "version": "0.44.0",
"license": "Apache-2.0",
"dependencies": {
"@stoplight/spectral-formats": "^1.1.0",
"@stoplight/spectral-functions": "^1.6.1",
"@stoplight/spectral-rulesets": "^1.6.0",
+ "@stoplight/spectral-runtime": "^1.1.2",
"lodash": "^4.17.21"
},
"devDependencies": {
@@ -12942,10 +12943,10 @@
},
"packages/validator": {
"name": "ibm-openapi-validator",
- "version": "0.94.0",
+ "version": "0.95.0",
"license": "Apache-2.0",
"dependencies": {
- "@ibm-cloud/openapi-ruleset": "0.43.0",
+ "@ibm-cloud/openapi-ruleset": "0.44.0",
"@stoplight/spectral-cli": "^6.4.2",
"@stoplight/spectral-core": "^1.12.4",
"@stoplight/spectral-parsers": "^1.0.1",
@@ -13441,6 +13442,7 @@
"@stoplight/spectral-formats": "^1.1.0",
"@stoplight/spectral-functions": "^1.6.1",
"@stoplight/spectral-rulesets": "^1.6.0",
+ "@stoplight/spectral-runtime": "^1.1.2",
"jest": "^27.4.5",
"lodash": "^4.17.21"
}
@@ -16932,7 +16934,7 @@
"ibm-openapi-validator": {
"version": "file:packages/validator",
"requires": {
- "@ibm-cloud/openapi-ruleset": "0.43.0",
+ "@ibm-cloud/openapi-ruleset": "0.44.0",
"@stoplight/spectral-cli": "^6.4.2",
"@stoplight/spectral-core": "^1.12.4",
"@stoplight/spectral-parsers": "^1.0.1",
diff --git a/packages/ruleset/package.json b/packages/ruleset/package.json
index be22d0e4d..6758dec29 100644
--- a/packages/ruleset/package.json
+++ b/packages/ruleset/package.json
@@ -17,6 +17,7 @@
"@stoplight/spectral-formats": "^1.1.0",
"@stoplight/spectral-functions": "^1.6.1",
"@stoplight/spectral-rulesets": "^1.6.0",
+ "@stoplight/spectral-runtime": "^1.1.2",
"lodash": "^4.17.21"
},
"devDependencies": {
diff --git a/packages/ruleset/src/functions/composed-schema-restrictions.js b/packages/ruleset/src/functions/composed-schema-restrictions.js
new file mode 100644
index 000000000..f318520a9
--- /dev/null
+++ b/packages/ruleset/src/functions/composed-schema-restrictions.js
@@ -0,0 +1,89 @@
+const {
+ validateSubschemas,
+ getPropertySchemasByName,
+ getSchemaType,
+ isObjectSchema,
+ isArraySchema,
+ isEnumerationSchema,
+ isObject,
+ SchemaType
+} = require('../utils');
+
+module.exports = function(schema, options, { path }) {
+ return validateSubschemas(schema, path, composedSchemaRestrictions);
+};
+
+/**
+ * Checks to make sure properties across a composed object schema have types that are deemed
+ * compatible for modeling that schema.
+ *
+ * @param {*} schema the schema to check
+ * @param {*} path the array of path segments indicating the location of "schema" within the API definition
+ * @returns an array containing the violations found or [] if no violations
+ */
+function composedSchemaRestrictions(schema, path) {
+ const errors = [];
+
+ // Only object schemas have properties
+ if (isObjectSchema(schema)) {
+ // Collects all composed property schemas indexed by the name of the property they define
+ const schemasByName = getPropertySchemasByName(schema);
+
+ for (const propertyName in schemasByName) {
+ // The reducer will result in a `false` sentinel if two schemas for the same property
+ // are not deemed compatible
+ if (
+ schemasByName[propertyName].reduce(schemaCompatibilityReducer) === false
+ ) {
+ errors.push({
+ message: `SDK generation may fail due to incompatible types for property across composite object schema: ${propertyName}`,
+ path
+ });
+ }
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Reducer for an array of schemas; for each pair of schemas returns one of them if they're deemed
+ * compatible and a `false` sentinel otherwise. The `false` sentinel is guaranteed to propagate.
+ *
+ * @param {*} left the "left" schema in the comparison
+ * @param {*} right the "right" schema in the comparison
+ * @returns a schema for the next comparison if the two are compatible, `false` otherwise
+ */
+function schemaCompatibilityReducer(left, right) {
+ return getComparandum(left) === getComparandum(right) ? left : false;
+}
+
+/**
+ * Returns the value appropriate to compare a schema to another schema. This is dependent on type.
+ * - For indeterminate schemas, deem incomparable and return a unique value
+ * - For object schemas, deem compatible only for an exact match and return the schema itself
+ * - For array schemas, deem compatible if their items are compatible (recursive)
+ * - For enumeration schemas, deem them compatible with strings
+ * - For all other schemas, deem them compatible with schemas of the same derived type and return it
+ *
+ * @param {*} schema the schema from which to derive a "comparandum" to compare with other schemas
+ * @returns a "comparandum" value to compare with other schemas
+ */
+function getComparandum(schema) {
+ if (!isObject(schema) || getSchemaType(schema) === SchemaType.UNKNOWN) {
+ // not compatible with any other schema
+ return Symbol();
+ }
+ if (isObjectSchema(schema)) {
+ // only compatible with same exact schema
+ return schema;
+ } else if (isArraySchema(schema)) {
+ // compatible with other arrays whose values are compatible
+ return getComparandum(schema.items);
+ } else if (isEnumerationSchema(schema)) {
+ // compatible with all strings
+ return SchemaType.STRING;
+ } else {
+ return getSchemaType(schema);
+ }
+}
diff --git a/packages/ruleset/src/functions/index.js b/packages/ruleset/src/functions/index.js
index cde9bcbc9..78f393be6 100644
--- a/packages/ruleset/src/functions/index.js
+++ b/packages/ruleset/src/functions/index.js
@@ -7,6 +7,7 @@ module.exports = {
checkMajorVersion: require('./check-major-version'),
circularRefs: require('./circular-refs'),
collectionArrayProperty: require('./collection-array-property'),
+ composedSchemaRestrictions: require('./composed-schema-restrictions'),
consecutivePathParamSegments: require('./consecutive-path-param-segments'),
deleteBody: require('./delete-body'),
descriptionMentionsJSON: require('./description-mentions-json'),
diff --git a/packages/ruleset/src/functions/inline-schema-rules.js b/packages/ruleset/src/functions/inline-schema-rules.js
index fffa47d22..6599c9510 100644
--- a/packages/ruleset/src/functions/inline-schema-rules.js
+++ b/packages/ruleset/src/functions/inline-schema-rules.js
@@ -3,7 +3,7 @@ const {
isJsonMimeType,
isArraySchema,
isEmptyObjectSchema,
- isPrimitiveType,
+ isPrimitiveSchema,
isRefSiblingSchema,
validateSubschemas
} = require('../utils');
@@ -35,7 +35,7 @@ function inlineResponseSchema(schema, options, { path }) {
if (
!schema.$ref &&
isJsonMimeType(mimeType) &&
- !isPrimitiveType(schema) &&
+ !isPrimitiveSchema(schema) &&
!arrayItemsAreRefOrPrimitive(schema) &&
!isRefSiblingSchema(schema)
) {
@@ -72,7 +72,7 @@ function inlineRequestSchema(schema, options, { path }) {
if (
!schema.$ref &&
isJsonMimeType(mimeType) &&
- !isPrimitiveType(schema) &&
+ !isPrimitiveSchema(schema) &&
!arrayItemsAreRefOrPrimitive(schema) &&
!isRefSiblingSchema(schema)
) {
@@ -111,9 +111,11 @@ function inlineRequestSchema(schema, options, { path }) {
* @returns an array containing the violations found or [] if no violations
*/
function inlinePropertySchema(schema, options, { path }) {
- // Check each sub-schema that is reachable from "schema" (properties,
- // additionalProperties, allOf/anyOf/oneOf, array items, etc.) .
- return validateSubschemas(schema, path, checkForInlineNestedObjectSchema);
+ // If "schema" is not a primitive, then check each sub-schema that is reachable from
+ // "schema" (properties, additionalProperties, allOf/anyOf/oneOf, array items, etc.).
+ return isPrimitiveSchema(schema)
+ ? []
+ : validateSubschemas(schema, path, checkForInlineNestedObjectSchema);
}
/**
@@ -129,7 +131,7 @@ function checkForInlineNestedObjectSchema(schema, path) {
// then bail out now to avoid a warning.
if (
schema.$ref ||
- isPrimitiveType(schema) ||
+ isPrimitiveSchema(schema) ||
isRefSiblingSchema(schema) ||
isEmptyObjectSchema(schema) ||
isArraySchema(schema)
@@ -182,5 +184,5 @@ function checkForInlineNestedObjectSchema(schema, path) {
function arrayItemsAreRefOrPrimitive(schema) {
const isArray = isArraySchema(schema);
const items = isArray && getCompositeSchemaAttribute(schema, 'items');
- return items && (items.$ref || isPrimitiveType(items));
+ return items && (items.$ref || isPrimitiveSchema(items));
}
diff --git a/packages/ruleset/src/functions/schema-type.js b/packages/ruleset/src/functions/schema-type.js
index 089135545..2f835957c 100644
--- a/packages/ruleset/src/functions/schema-type.js
+++ b/packages/ruleset/src/functions/schema-type.js
@@ -1,31 +1,77 @@
-const { mergeAllOfSchemaProperties, validateSubschemas } = require('../utils');
+const {
+ SchemaType,
+ JSONSchemaType,
+ JSONSchemaFormat,
+ checkCompositeSchemaForConstraint,
+ getAllComposedSchemas,
+ getSchemaType,
+ validateNestedSchemas
+} = require('../utils');
-module.exports = function(schema, _opts, { path }) {
- return validateSubschemas(schema, path, schemaType);
+module.exports = function(schema, options, { path }) {
+ return validateNestedSchemas(schema, path, schemaType);
};
function schemaType(schema, path) {
- // If we're looking at an allOf list element schema, then
- // bail out as this would not necessarily provide the full
- // definition of a schema or schema property.
- if (path[path.length - 2] === 'allOf') {
+ const type = getSchemaType(schema);
+ let message = null;
+
+ if (type === SchemaType.UNKNOWN) {
+ message =
+ 'Schema does not have a clear, consistent combination of `type` and `format`.';
+ } else if (hasAmbiguousType(schema, type)) {
+ message = '`type` is undefined for a variation of the schema.';
+ } else if (hasContradictoryType(schema, type)) {
+ message = '`type` is contradictory in the schema composition.';
+ } else if (type in JSONSchemaFormat) {
+ if (hasAmbiguousFormat(schema, type)) {
+ message = '`format` is undefined for a variation of the schema.';
+ } else if (hasContradictoryFormat(schema, type)) {
+ message = '`format` is contradictory in the schema composition.';
+ }
+ }
+
+ if (message !== null) {
+ return [{ message, path }];
+ } else {
return [];
}
+}
- const mergedSchema = mergeAllOfSchemaProperties(schema);
+const hasAmbiguousType = (schema, type) => {
+ return !checkCompositeSchemaForConstraint(
+ schema,
+ s => s.type === JSONSchemaType[type]
+ );
+};
+
+const hasContradictoryType = (schema, type) => {
+ const schemas = getAllComposedSchemas(schema);
- if (!schemaHasType(mergedSchema)) {
- return [
- {
- message: 'Schema should have a non-empty `type` field.',
- path
- }
- ];
+ for (const s of schemas) {
+ if ('type' in s && s.type !== JSONSchemaType[type]) {
+ return true;
+ }
}
- return [];
-}
+ return false;
+};
-function schemaHasType(s) {
- return s && s.type && s.type.toString().trim().length;
-}
+const hasAmbiguousFormat = (schema, type) => {
+ return !checkCompositeSchemaForConstraint(
+ schema,
+ s => s.format === JSONSchemaFormat[type]
+ );
+};
+
+const hasContradictoryFormat = (schema, type) => {
+ const schemas = getAllComposedSchemas(schema);
+
+ for (const s of schemas) {
+ if ('format' in s && s.format !== JSONSchemaFormat[type]) {
+ return true;
+ }
+ }
+
+ return false;
+};
diff --git a/packages/ruleset/src/ibm-oas.js b/packages/ruleset/src/ibm-oas.js
index 95a2f8bc4..ae32f5a18 100644
--- a/packages/ruleset/src/ibm-oas.js
+++ b/packages/ruleset/src/ibm-oas.js
@@ -99,6 +99,7 @@ module.exports = {
'binary-schemas': ibmRules.binarySchemas,
'circular-refs': ibmRules.circularRefs,
'collection-array-property': ibmRules.collectionArrayProperty,
+ 'composed-schema-restrictions': ibmRules.composedSchemaRestrictions,
'consecutive-path-param-segments': ibmRules.consecutivePathParamSegments,
'content-entry-contains-schema': ibmRules.contentEntryContainsSchema,
'content-entry-provided': ibmRules.contentEntryProvided,
diff --git a/packages/ruleset/src/rules/composed-schema-restrictions.js b/packages/ruleset/src/rules/composed-schema-restrictions.js
new file mode 100644
index 000000000..29b95f009
--- /dev/null
+++ b/packages/ruleset/src/rules/composed-schema-restrictions.js
@@ -0,0 +1,16 @@
+const { oas3 } = require('@stoplight/spectral-formats');
+const { composedSchemaRestrictions } = require('../functions');
+const { schemas } = require('../collections');
+
+module.exports = {
+ description:
+ 'Composed schemas using oneOf or anyOf should comply with SDK generation best practices',
+ message: '{{error}}',
+ given: schemas,
+ severity: 'warn',
+ formats: [oas3],
+ resolved: true,
+ then: {
+ function: composedSchemaRestrictions
+ }
+};
diff --git a/packages/ruleset/src/rules/index.js b/packages/ruleset/src/rules/index.js
index 8faf37957..d15bdf581 100644
--- a/packages/ruleset/src/rules/index.js
+++ b/packages/ruleset/src/rules/index.js
@@ -8,6 +8,7 @@ module.exports = {
binarySchemas: require('./binary-schemas'),
circularRefs: require('./circular-refs'),
collectionArrayProperty: require('./collection-array-property'),
+ composedSchemaRestrictions: require('./composed-schema-restrictions'),
consecutivePathParamSegments: require('./consecutive-path-param-segments'),
contentEntryContainsSchema: require('./content-entry-contains-schema'),
contentEntryProvided: require('./content-entry-provided'),
diff --git a/packages/ruleset/src/rules/schema-type.js b/packages/ruleset/src/rules/schema-type.js
index 43836ae63..eaddef932 100644
--- a/packages/ruleset/src/rules/schema-type.js
+++ b/packages/ruleset/src/rules/schema-type.js
@@ -3,11 +3,10 @@ const { schemaType } = require('../functions');
const { schemas } = require('../collections');
module.exports = {
- description:
- 'Schemas and schema properties should have a non-empty `type` field. **This rule is disabled by default.**',
+ description: 'Schemas must have an explicit and consistent type',
message: '{{error}}',
given: schemas,
- severity: 'off',
+ severity: 'warn',
formats: [oas2, oas3],
resolved: true,
then: {
diff --git a/packages/ruleset/src/utils/get-all-composed-schemas.js b/packages/ruleset/src/utils/get-all-composed-schemas.js
new file mode 100644
index 000000000..1e291a710
--- /dev/null
+++ b/packages/ruleset/src/utils/get-all-composed-schemas.js
@@ -0,0 +1,46 @@
+const isObject = require('./is-object');
+
+/**
+ * Returns an array of all schemas composed into a schema, optionally filtered by a lambda function,
+ * and optionally including the schema itself. This function is useful if you need to see across
+ * all composed schemas without caring about the exact implication of the schema (such as whether it
+ * represents a necessary constraint as in `allOf` or a possible constraint such as in `oneOf`).
+ *
+ * @param {object} schema - Simple or composite OpenAPI 3.0 schema object.
+ * @param {function} schemaFilter(schema, applicators) - Lambda filter for schemas
+ * @param {boolean} includeSelf - Option to include the schema itself (defaults to `true`)
+ * @returns {array} - Array of composed schemas
+ */
+const getAllComposedSchemas = (
+ schema,
+ schemaFilter = () => true,
+ includeSelf = true,
+ applicators = []
+) => {
+ const schemas = [];
+
+ if (!isObject(schema)) {
+ return schemas;
+ }
+
+ if (includeSelf && schemaFilter(schema, applicators)) {
+ schemas.push(schema);
+ }
+
+ for (const applicatorType of ['allOf', 'oneOf', 'anyOf']) {
+ if (Array.isArray(schema[applicatorType])) {
+ for (const applicatorSchema of schema[applicatorType]) {
+ schemas.push(
+ ...getAllComposedSchemas(applicatorSchema, schemaFilter, true, [
+ ...applicators,
+ applicatorType
+ ])
+ );
+ }
+ }
+ }
+
+ return [...new Set(schemas)]; // de-duplicate
+};
+
+module.exports = getAllComposedSchemas;
diff --git a/packages/ruleset/src/utils/get-property-names-for-schema.js b/packages/ruleset/src/utils/get-property-names-for-schema.js
index 9189e1c8b..e058ee768 100644
--- a/packages/ruleset/src/utils/get-property-names-for-schema.js
+++ b/packages/ruleset/src/utils/get-property-names-for-schema.js
@@ -1,35 +1,24 @@
+const getAllComposedSchemas = require('./get-all-composed-schemas');
const isObject = require('./is-object');
/**
* Returns an array of property names for a simple or composite schema,
* optionally filtered by a lambda function.
*
- * @param {object} schema - Simple or composite OpenAPI 3.0 schema object.
- * @param {array} path - Path array for the provided schema.
- * @param {function} validate - Validate function.
- * @returns {array} - Array of validation errors.
+ * @param {object} schema - Simple or composite OpenAPI 3.0 schema object
+ * @param {function} schemaFilter(propertyName, propertySchema) - Lambda filter for properties
+ * @returns {array} - Array of property names defined for a schema
*/
const getPropertyNamesForSchema = (schema, propertyFilter = () => true) => {
const propertyNames = [];
+ const schemas = getAllComposedSchemas(schema);
- if (!isObject(schema)) {
- return propertyNames;
- }
-
- if (isObject(schema.properties)) {
- for (const propertyName of Object.keys(schema.properties)) {
- if (propertyFilter(propertyName, schema.properties[propertyName])) {
- propertyNames.push(propertyName);
- }
- }
- }
-
- for (const applicatorType of ['allOf', 'oneOf', 'anyOf']) {
- if (Array.isArray(schema[applicatorType])) {
- for (const applicatorSchema of schema[applicatorType]) {
- propertyNames.push(
- ...getPropertyNamesForSchema(applicatorSchema, propertyFilter)
- );
+ for (const s of schemas) {
+ if (isObject(s.properties)) {
+ for (const propertyName of Object.keys(s.properties)) {
+ if (propertyFilter(propertyName, s.properties[propertyName])) {
+ propertyNames.push(propertyName);
+ }
}
}
}
diff --git a/packages/ruleset/src/utils/get-property-schemas-by-name.js b/packages/ruleset/src/utils/get-property-schemas-by-name.js
new file mode 100644
index 000000000..346c8c0d0
--- /dev/null
+++ b/packages/ruleset/src/utils/get-property-schemas-by-name.js
@@ -0,0 +1,29 @@
+const getAllComposedSchemas = require('./get-all-composed-schemas');
+const isObject = require('./is-object');
+
+/**
+ * Returns a dictionary object of property schemas in arrays keyed by property name for a simple or
+ * composite schema.
+ *
+ * @param {object} schema - Simple or composite OpenAPI 3.0 schema object
+ * @returns {object} - Dictionary mapping property name to collected schemas[]
+ */
+const getPropertySchemasByName = schema => {
+ const dictionary = {};
+ const schemas = getAllComposedSchemas(schema);
+
+ for (const s of schemas) {
+ if (isObject(s.properties)) {
+ for (const propertyName of Object.keys(s.properties)) {
+ if (!Array.isArray(dictionary[propertyName])) {
+ dictionary[propertyName] = [];
+ }
+ dictionary[propertyName].push(s.properties[propertyName]);
+ }
+ }
+ }
+
+ return dictionary;
+};
+
+module.exports = getPropertySchemasByName;
diff --git a/packages/ruleset/src/utils/get-schema-type.js b/packages/ruleset/src/utils/get-schema-type.js
index 85dbee9d5..7d89ef2c6 100644
--- a/packages/ruleset/src/utils/get-schema-type.js
+++ b/packages/ruleset/src/utils/get-schema-type.js
@@ -28,8 +28,37 @@ const SchemaType = {
UNKNOWN: Symbol('unknown')
};
+const JSONSchemaType = {
+ [SchemaType.ARRAY]: 'array',
+ [SchemaType.BINARY]: 'string',
+ [SchemaType.BOOLEAN]: 'boolean',
+ [SchemaType.BYTE]: 'string',
+ [SchemaType.DATE]: 'string',
+ [SchemaType.DATE_TIME]: 'string',
+ [SchemaType.DOUBLE]: 'number',
+ [SchemaType.ENUMERATION]: 'string',
+ [SchemaType.FLOAT]: 'number',
+ [SchemaType.INT32]: 'integer',
+ [SchemaType.INT64]: 'integer',
+ [SchemaType.INTEGER]: 'integer',
+ [SchemaType.NUMBER]: 'number',
+ [SchemaType.OBJECT]: 'object',
+ [SchemaType.STRING]: 'string'
+};
+
+const JSONSchemaFormat = {
+ [SchemaType.BINARY]: 'binary',
+ [SchemaType.BYTE]: 'byte',
+ [SchemaType.DATE]: 'date',
+ [SchemaType.DATE_TIME]: 'date-time',
+ [SchemaType.DOUBLE]: 'double',
+ [SchemaType.FLOAT]: 'float',
+ [SchemaType.INT32]: 'int32',
+ [SchemaType.INT64]: 'int64'
+};
+
/**
- * Returns a symbol from SchemaType based on the most specific schema type detected for a schema.
+ *] Returns a symbol from SchemaType based on the most specific schema type detected for a schema.
*
* This function is a heuristic and does not attempt to account for contradictions, schemas which
* give no consistent indication of type, or OAS 3.1 schemas which use a type array. It also does
@@ -108,7 +137,7 @@ const getSchemaType = schema => {
const isArraySchema = schema => {
return checkCompositeSchemaForConstraint(schema, s => {
if ('type' in s) {
- return s.type === 'array'; // ignores the possibility of type arrays in OAS 3.1
+ return s.type === JSONSchemaType[SchemaType.ARRAY]; // ignores the possibility of type arrays in OAS 3.1
} else {
return isObject(s.items);
}
@@ -124,7 +153,9 @@ const isArraySchema = schema => {
const isBinarySchema = schema => {
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'string' && s.format === 'binary'
+ s =>
+ s.type === JSONSchemaType[SchemaType.BINARY] &&
+ s.format === JSONSchemaFormat[SchemaType.BINARY]
);
};
@@ -135,7 +166,10 @@ const isBinarySchema = schema => {
* @returns {boolean}
*/
const isBooleanSchema = schema => {
- return checkCompositeSchemaForConstraint(schema, s => s.type === 'boolean');
+ return checkCompositeSchemaForConstraint(
+ schema,
+ s => s.type === JSONSchemaType[SchemaType.BOOLEAN]
+ );
};
/**
@@ -147,7 +181,9 @@ const isBooleanSchema = schema => {
const isByteSchema = schema => {
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'string' && s.format === 'byte'
+ s =>
+ s.type === JSONSchemaType[SchemaType.BYTE] &&
+ s.format === JSONSchemaFormat[SchemaType.BYTE]
);
};
@@ -160,7 +196,9 @@ const isByteSchema = schema => {
const isDateSchema = schema => {
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'string' && s.format === 'date'
+ s =>
+ s.type === JSONSchemaType[SchemaType.DATE] &&
+ s.format === JSONSchemaFormat[SchemaType.DATE]
);
};
@@ -173,7 +211,9 @@ const isDateSchema = schema => {
const isDateTimeSchema = schema => {
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'string' && s.format === 'date-time'
+ s =>
+ s.type === JSONSchemaType[SchemaType.DATE] &&
+ s.format === JSONSchemaFormat[SchemaType.DATE_TIME]
);
};
@@ -187,7 +227,9 @@ const isDoubleSchema = schema => {
// also ignores `type` and `format` defined in separately composited schemas
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'number' && s.format === 'double'
+ s =>
+ s.type === JSONSchemaType[SchemaType.DOUBLE] &&
+ s.format === JSONSchemaFormat[SchemaType.DOUBLE]
);
};
@@ -200,7 +242,8 @@ const isDoubleSchema = schema => {
const isEnumerationSchema = schema => {
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'string' && Array.isArray(s.enum)
+ s =>
+ s.type === JSONSchemaType[SchemaType.ENUMERATION] && Array.isArray(s.enum)
);
};
@@ -214,7 +257,9 @@ const isFloatSchema = schema => {
// also ignores `type` and `format` defined in separately composited schemas
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'number' && s.format === 'float'
+ s =>
+ s.type === JSONSchemaType[SchemaType.FLOAT] &&
+ s.format === JSONSchemaFormat[SchemaType.FLOAT]
);
};
@@ -227,7 +272,9 @@ const isFloatSchema = schema => {
const isInt32Schema = schema => {
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'integer' && s.format === 'int32'
+ s =>
+ s.type === JSONSchemaType[SchemaType.INT32] &&
+ s.format === JSONSchemaFormat[SchemaType.INT32]
);
};
@@ -240,7 +287,9 @@ const isInt32Schema = schema => {
const isInt64Schema = schema => {
return checkCompositeSchemaForConstraint(
schema,
- s => s.type === 'integer' && s.format === 'int64'
+ s =>
+ s.type === JSONSchemaType[SchemaType.INT64] &&
+ s.format === JSONSchemaFormat[SchemaType.INT64]
);
};
@@ -251,7 +300,10 @@ const isInt64Schema = schema => {
* @returns {boolean}
*/
const isIntegerSchema = schema => {
- return checkCompositeSchemaForConstraint(schema, s => s.type === 'integer');
+ return checkCompositeSchemaForConstraint(
+ schema,
+ s => s.type === JSONSchemaType[SchemaType.INTEGER]
+ );
};
/**
@@ -261,7 +313,10 @@ const isIntegerSchema = schema => {
* @returns {boolean}
*/
const isNumberSchema = schema => {
- return checkCompositeSchemaForConstraint(schema, s => s.type === 'number');
+ return checkCompositeSchemaForConstraint(
+ schema,
+ s => s.type === JSONSchemaType[SchemaType.NUMBER]
+ );
};
/**
@@ -273,7 +328,7 @@ const isNumberSchema = schema => {
const isObjectSchema = schema => {
return checkCompositeSchemaForConstraint(schema, s => {
if ('type' in s) {
- return s.type === 'object'; // ignores the possibility of type arrays in OAS 3.1
+ return s.type === JSONSchemaType[SchemaType.OBJECT]; // ignores the possibility of type arrays in OAS 3.1
} else {
return (
isObject(s.properties) ||
@@ -291,11 +346,39 @@ const isObjectSchema = schema => {
* @returns {boolean}
*/
const isStringSchema = schema => {
- return checkCompositeSchemaForConstraint(schema, s => s.type === 'string');
+ return checkCompositeSchemaForConstraint(
+ schema,
+ s => s.type === JSONSchemaType[SchemaType.STRING]
+ );
};
+/**
+ * Returns true if "schema" is a primitive schema (string, integer, number, boolean)
+ * @param {*} schema the schema to check
+ * @returns boolean
+ */
+function isPrimitiveSchema(s) {
+ const primitiveTypes = [
+ SchemaType.BOOLEAN,
+ SchemaType.BYTE,
+ SchemaType.DOUBLE,
+ SchemaType.ENUMERATION,
+ SchemaType.FLOAT,
+ SchemaType.INT32,
+ SchemaType.INT64,
+ SchemaType.INTEGER,
+ SchemaType.NUMBER,
+ SchemaType.STRING
+ ];
+
+ const type = getSchemaType(s);
+ return primitiveTypes.includes(type);
+}
+
module.exports = {
SchemaType,
+ JSONSchemaType,
+ JSONSchemaFormat,
getSchemaType,
isArraySchema,
isBinarySchema,
@@ -311,5 +394,6 @@ module.exports = {
isIntegerSchema,
isNumberSchema,
isObjectSchema,
+ isPrimitiveSchema,
isStringSchema
};
diff --git a/packages/ruleset/src/utils/index.js b/packages/ruleset/src/utils/index.js
index 5318c3036..9bc680ece 100644
--- a/packages/ruleset/src/utils/index.js
+++ b/packages/ruleset/src/utils/index.js
@@ -1,12 +1,13 @@
module.exports = {
checkCompositeSchemaForConstraint: require('./check-composite-schema-for-constraint'),
checkCompositeSchemaForProperty: require('./check-composite-schema-for-property'),
+ getAllComposedSchemas: require('./get-all-composed-schemas'),
getCompositeSchemaAttribute: require('./get-composite-schema-attribute'),
getPropertyNamesForSchema: require('./get-property-names-for-schema'),
+ getPropertySchemasByName: require('./get-property-schemas-by-name'),
isDeprecated: require('./is-deprecated'),
isEmptyObjectSchema: require('./is-empty-object-schema'),
isObject: require('./is-object'),
- isPrimitiveType: require('./is-primitive-type'),
isRefSiblingSchema: require('./is-ref-sibling-schema'),
mergeAllOfSchemaProperties: require('./merge-allof-schema-properties'),
...require('./mimetype-utils'),
diff --git a/packages/ruleset/src/utils/is-primitive-type.js b/packages/ruleset/src/utils/is-primitive-type.js
deleted file mode 100644
index d708e932b..000000000
--- a/packages/ruleset/src/utils/is-primitive-type.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * Returns true if "schema" is a primitive schema.
- * @param {*} schema the schema to check
- * @returns boolean
- */
-function isPrimitiveType(schema) {
- return (
- schema.type &&
- ['boolean', 'integer', 'number', 'string'].includes(schema.type)
- );
-}
-
-module.exports = isPrimitiveType;
diff --git a/packages/ruleset/test/composed-schema-restrictions.test.js b/packages/ruleset/test/composed-schema-restrictions.test.js
new file mode 100644
index 000000000..c07fece72
--- /dev/null
+++ b/packages/ruleset/test/composed-schema-restrictions.test.js
@@ -0,0 +1,625 @@
+const { composedSchemaRestrictions } = require('../src/rules');
+const { makeCopy, rootDocument, testRule, severityCodes } = require('./utils');
+
+const rule = composedSchemaRestrictions;
+const ruleId = 'composed-schema-restrictions';
+const expectedSeverity = severityCodes.warning;
+const expectedMsgObjSchema = `A schema within an object schema's oneOf/anyOf list must be an object schema`;
+const expectedMsgProp = `SDK generation may fail due to incompatible types for property across composite object schema:`;
+
+describe('Spectral rule: schema-description', () => {
+ describe('Should not yield errors', () => {
+ it('Clean spec', async () => {
+ const results = await testRule(ruleId, rule, rootDocument);
+ expect(results).toHaveLength(0);
+ });
+
+ it('Primitive property w/oneOf', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ testDocument.components.schemas['Drink'].properties = {
+ type: {
+ type: 'string',
+ oneOf: [
+ {
+ enum: ['soda']
+ },
+ {
+ enum: ['juice']
+ }
+ ]
+ }
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(0);
+ });
+
+ it('Primitive `items` schema w/anyOf', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ testDocument.components.schemas['Drink'].properties = {
+ drink_types: {
+ type: 'array',
+ items: {
+ type: 'string',
+ anyOf: [
+ {
+ minLength: 1,
+ maxLength: 10
+ },
+ {
+ enum: ['juice']
+ }
+ ]
+ }
+ }
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(0);
+ });
+
+ it('Primitive `additionalProperties` schema w/oneOf', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ testDocument.components.schemas['Drink'].additionalProperties = {
+ type: 'string',
+ oneOf: [
+ {
+ minLength: 1,
+ maxLength: 10
+ },
+ {
+ enum: ['foo', 'bar']
+ }
+ ]
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(0);
+ });
+
+ it('Compatible properties defined in allOf', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ testDocument.components.schemas['Drink'].allOf = [
+ {
+ properties: {
+ float_prop: {
+ type: 'number',
+ format: 'float'
+ }
+ }
+ },
+ {
+ properties: {
+ double_prop: {
+ type: 'number',
+ format: 'double'
+ }
+ }
+ }
+ ];
+
+ testDocument.components.schemas['Soda'].allOf = [
+ {
+ properties: {
+ float_prop: {
+ type: 'number',
+ format: 'float'
+ }
+ }
+ }
+ ];
+
+ testDocument.components.schemas['Juice'].allOf = [
+ {
+ properties: {
+ double_prop: {
+ type: 'number',
+ format: 'double'
+ }
+ }
+ }
+ ];
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(0);
+ });
+
+ it('Compatible properties that are objects (refs)', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const objectSchema = {
+ type: 'object',
+ properties: {
+ foo: {
+ type: 'string'
+ },
+ bar: {
+ type: 'integer'
+ }
+ }
+ };
+
+ const schemaRef = {
+ $ref: '#/components/schemas/ObjectSchema'
+ };
+
+ testDocument.components.schemas['ObjectSchema'] = objectSchema;
+
+ testDocument.components.schemas['Drink'].properties = {
+ object_prop: schemaRef
+ };
+
+ testDocument.components.schemas[
+ 'Soda'
+ ].properties.object_prop = schemaRef;
+
+ testDocument.components.schemas[
+ 'Juice'
+ ].properties.object_prop = schemaRef;
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(0);
+ });
+
+ it('Compatible properties that are objects (refs, allOf)', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const objectSchema = {
+ allOf: [
+ {
+ properties: {
+ foo: {
+ type: 'string'
+ }
+ }
+ },
+ {
+ properties: {
+ bar: {
+ type: 'integer'
+ }
+ }
+ }
+ ]
+ };
+
+ testDocument.components.schemas['ObjectSchema'] = objectSchema;
+
+ const schemaRef = {
+ $ref: '#/components/schemas/ObjectSchema'
+ };
+
+ testDocument.components.schemas['Drink'].properties = {
+ object_prop: schemaRef
+ };
+
+ testDocument.components.schemas[
+ 'Soda'
+ ].properties.object_prop = schemaRef;
+
+ testDocument.components.schemas[
+ 'Juice'
+ ].properties.object_prop = schemaRef;
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(0);
+ });
+
+ it('Empty oneOf', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ testDocument.components.schemas['Drink'].additionalProperties = {
+ type: 'object',
+ properties: {
+ oneOf: {
+ type: 'string'
+ }
+ },
+ oneOf: []
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(0);
+ });
+
+ it('Empty anyOf', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ testDocument.components.schemas['Drink'].additionalProperties = {
+ type: 'object',
+ properties: {
+ anyOf: {
+ type: 'string'
+ }
+ },
+ anyOf: []
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(0);
+ });
+ });
+
+ describe('Should yield errors', () => {
+ it('AnyOf with non-object schemas', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const newDrinkSchema = makeCopy(testDocument.components.schemas['Drink']);
+
+ const notAnObject = {
+ description: 'non-object schema within anyOf',
+ anyOf: [
+ {
+ type: 'string',
+ format: 'date-time'
+ },
+ {
+ properties: {
+ foo: {
+ type: 'string'
+ }
+ }
+ }
+ ]
+ };
+
+ testDocument.components.schemas['NewDrink'] = newDrinkSchema;
+ testDocument.components.schemas['NotAnObject'] = notAnObject;
+
+ newDrinkSchema.additionalProperties = {
+ $ref: '#/components/schemas/NotAnObject'
+ };
+
+ testDocument.components.schemas[
+ 'DrinkCollection'
+ ].allOf[1].properties.drinks.items.$ref = '#/components/schemas/NewDrink';
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe(ruleId);
+ expect(results[0].message).toBe(expectedMsgObjSchema);
+ expect(results[0].severity).toBe(expectedSeverity);
+ expect(results[0].path.join('.')).toBe(
+ 'paths./v1/drinks.get.responses.200.content.application/json.schema.allOf.1.properties.drinks.items.additionalProperties'
+ );
+ });
+
+ it('Incompatible properties defined in main & subschema', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const newDrinkSchema = makeCopy(testDocument.components.schemas['Drink']);
+ const newJuiceSchema = makeCopy(testDocument.components.schemas['Juice']);
+
+ testDocument.components.schemas['NewDrink'] = newDrinkSchema;
+ testDocument.components.schemas['NewJuice'] = newJuiceSchema;
+
+ testDocument.components.schemas[
+ 'DrinkCollection'
+ ].allOf[1].properties.drinks.items.$ref = '#/components/schemas/NewDrink';
+ newDrinkSchema.oneOf[0].$ref = '#/components/schemas/NewJuice';
+
+ newDrinkSchema.properties = {
+ drink_size: {
+ type: 'integer',
+ format: 'int64'
+ }
+ };
+ newJuiceSchema.properties.drink_size = {
+ type: 'integer',
+ format: 'int32'
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe(ruleId);
+ expect(results[0].message).toBe(`${expectedMsgProp} drink_size`);
+ expect(results[0].severity).toBe(expectedSeverity);
+ expect(results[0].path.join('.')).toBe(
+ 'paths./v1/drinks.get.responses.200.content.application/json.schema.allOf.1.properties.drinks.items'
+ );
+ });
+
+ it('Incompatible properties defined in two subschemas', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const newDrinkSchema = makeCopy(testDocument.components.schemas['Drink']);
+ const newJuiceSchema = makeCopy(testDocument.components.schemas['Juice']);
+ const newSodaSchema = makeCopy(testDocument.components.schemas['Soda']);
+
+ testDocument.components.schemas['NewDrink'] = newDrinkSchema;
+ testDocument.components.schemas['NewJuice'] = newJuiceSchema;
+ testDocument.components.schemas['NewSoda'] = newSodaSchema;
+
+ testDocument.components.schemas[
+ 'DrinkCollection'
+ ].allOf[1].properties.drinks.items.$ref = '#/components/schemas/NewDrink';
+ newDrinkSchema.oneOf[0].$ref = '#/components/schemas/NewJuice';
+ newDrinkSchema.oneOf[1].$ref = '#/components/schemas/NewSoda';
+
+ newSodaSchema.properties.bad_prop = {
+ type: 'string'
+ };
+ newJuiceSchema.properties.bad_prop = {
+ type: 'integer'
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe(ruleId);
+ expect(results[0].message).toBe(`${expectedMsgProp} bad_prop`);
+ expect(results[0].severity).toBe(expectedSeverity);
+ expect(results[0].path.join('.')).toBe(
+ 'paths./v1/drinks.get.responses.200.content.application/json.schema.allOf.1.properties.drinks.items'
+ );
+ });
+
+ it('Incompatible properties defined in subschema allOfs', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const newDrinkSchema = makeCopy(testDocument.components.schemas['Drink']);
+ const newSodaSchema = makeCopy(testDocument.components.schemas['Soda']);
+ const newJuiceSchema = makeCopy(testDocument.components.schemas['Juice']);
+
+ testDocument.components.schemas['NewDrink'] = newDrinkSchema;
+ testDocument.components.schemas['NewJuice'] = newJuiceSchema;
+ testDocument.components.schemas['NewSoda'] = newSodaSchema;
+
+ testDocument.components.schemas[
+ 'DrinkCollection'
+ ].allOf[1].properties.drinks.items.$ref = '#/components/schemas/NewDrink';
+ newDrinkSchema.oneOf[0].$ref = '#/components/schemas/NewJuice';
+ newDrinkSchema.oneOf[1].$ref = '#/components/schemas/NewSoda';
+
+ newSodaSchema.allOf = [
+ {
+ properties: {
+ bad_prop: {
+ type: 'string'
+ }
+ }
+ }
+ ];
+ newJuiceSchema.allOf = [
+ {
+ properties: {
+ bad_prop: {
+ type: 'integer'
+ }
+ }
+ }
+ ];
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe(ruleId);
+ expect(results[0].message).toBe(`${expectedMsgProp} bad_prop`);
+ expect(results[0].severity).toBe(expectedSeverity);
+ expect(results[0].path.join('.')).toBe(
+ 'paths./v1/drinks.get.responses.200.content.application/json.schema.allOf.1.properties.drinks.items'
+ );
+ });
+
+ it('Incompatible properties defined in complex compositions', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const newDrinkSchema = makeCopy(testDocument.components.schemas['Drink']);
+ const newSodaSchema = makeCopy(testDocument.components.schemas['Soda']);
+ const newJuiceSchema = makeCopy(testDocument.components.schemas['Juice']);
+
+ testDocument.components.schemas['NewDrink'] = newDrinkSchema;
+ testDocument.components.schemas['NewJuice'] = newJuiceSchema;
+ testDocument.components.schemas['NewSoda'] = newSodaSchema;
+
+ testDocument.components.schemas[
+ 'DrinkCollection'
+ ].allOf[1].properties.drinks.items.$ref = '#/components/schemas/NewDrink';
+ newDrinkSchema.oneOf[0].$ref = '#/components/schemas/NewJuice';
+ newDrinkSchema.oneOf[1].$ref = '#/components/schemas/NewSoda';
+
+ newSodaSchema.allOf = [
+ {
+ allOf: [
+ {
+ allOf: [
+ {
+ properties: {
+ bad_prop: {
+ type: 'string'
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ];
+ newJuiceSchema.allOf = [
+ {
+ allOf: [
+ {
+ allOf: [
+ {
+ allOf: [
+ {
+ properties: {
+ bad_prop: {
+ type: 'integer'
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ];
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe(ruleId);
+ expect(results[0].message).toBe(`${expectedMsgProp} bad_prop`);
+ expect(results[0].severity).toBe(expectedSeverity);
+ expect(results[0].path.join('.')).toBe(
+ 'paths./v1/drinks.get.responses.200.content.application/json.schema.allOf.1.properties.drinks.items'
+ );
+ });
+
+ it('Incompatible properties that are objects (non-refs)', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const newDrinkSchema = makeCopy(testDocument.components.schemas['Drink']);
+ const newJuiceSchema = makeCopy(testDocument.components.schemas['Juice']);
+
+ testDocument.components.schemas['NewDrink'] = newDrinkSchema;
+ testDocument.components.schemas['NewJuice'] = newJuiceSchema;
+
+ testDocument.components.schemas[
+ 'DrinkCollection'
+ ].allOf[1].properties.drinks.items.$ref = '#/components/schemas/NewDrink';
+ newDrinkSchema.oneOf[0].$ref = '#/components/schemas/NewJuice';
+
+ const objectSchema = {
+ type: 'object',
+ properties: {
+ foo: {
+ type: 'string'
+ },
+ bar: {
+ type: 'integer'
+ }
+ }
+ };
+
+ testDocument.components.schemas['NewDrink'].properties = {
+ object_prop: objectSchema
+ };
+
+ newJuiceSchema.properties.object_prop = {
+ allOf: [
+ {
+ objectSchema
+ },
+ {
+ description: 'This makes the schema different.'
+ }
+ ]
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe(ruleId);
+ expect(results[0].message).toBe(`${expectedMsgProp} object_prop`);
+ expect(results[0].severity).toBe(expectedSeverity);
+ expect(results[0].path.join('.')).toBe(
+ 'paths./v1/drinks.get.responses.200.content.application/json.schema.allOf.1.properties.drinks.items'
+ );
+ });
+
+ it('Incompatible properties that are objects (refs)', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const newDrinkSchema = makeCopy(testDocument.components.schemas['Drink']);
+ const newJuiceSchema = makeCopy(testDocument.components.schemas['Juice']);
+
+ testDocument.components.schemas['NewDrink'] = newDrinkSchema;
+ testDocument.components.schemas['NewJuice'] = newJuiceSchema;
+
+ testDocument.components.schemas[
+ 'DrinkCollection'
+ ].allOf[1].properties.drinks.items.$ref = '#/components/schemas/NewDrink';
+ newDrinkSchema.oneOf[0].$ref = '#/components/schemas/NewJuice';
+
+ const objectSchema = {
+ type: 'object',
+ properties: {
+ foo: {
+ type: 'string'
+ },
+ bar: {
+ type: 'integer'
+ }
+ }
+ };
+
+ // Two identical schemas with different names -> incompatible due
+ // to different datatypes in generated code.
+ testDocument.components.schemas['Object1'] = objectSchema;
+ testDocument.components.schemas['Object2'] = objectSchema;
+
+ testDocument.components.schemas['NewDrink'].properties = {
+ object_prop: {
+ $ref: '#/components/schemas/Object1'
+ }
+ };
+
+ newJuiceSchema.properties.object_prop = {
+ $ref: '#/components/schemas/Object2'
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe(ruleId);
+ expect(results[0].message).toBe(`${expectedMsgProp} object_prop`);
+ expect(results[0].severity).toBe(expectedSeverity);
+ expect(results[0].path.join('.')).toBe(
+ 'paths./v1/drinks.get.responses.200.content.application/json.schema.allOf.1.properties.drinks.items'
+ );
+ });
+
+ it('Incompatible properties that are objects but not refs', async () => {
+ const testDocument = makeCopy(rootDocument);
+
+ const newDrinkSchema = makeCopy(testDocument.components.schemas['Drink']);
+ const newJuiceSchema = makeCopy(testDocument.components.schemas['Juice']);
+
+ testDocument.components.schemas['NewDrink'] = newDrinkSchema;
+ testDocument.components.schemas['NewJuice'] = newJuiceSchema;
+
+ testDocument.components.schemas[
+ 'DrinkCollection'
+ ].allOf[1].properties.drinks.items.$ref = '#/components/schemas/NewDrink';
+ newDrinkSchema.oneOf[0].$ref = '#/components/schemas/NewJuice';
+
+ const objectSchema = {
+ type: 'object',
+ properties: {
+ foo: {
+ type: 'string'
+ },
+ bar: {
+ type: 'integer'
+ }
+ }
+ };
+
+ testDocument.components.schemas['NewDrink'].properties = {
+ object_prop: objectSchema
+ };
+
+ newJuiceSchema.properties.object_prop = {
+ allOf: [
+ {
+ objectSchema
+ },
+ {
+ description: 'This makes the schema different.'
+ }
+ ]
+ };
+
+ const results = await testRule(ruleId, rule, testDocument);
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe(ruleId);
+ expect(results[0].message).toBe(`${expectedMsgProp} object_prop`);
+ expect(results[0].severity).toBe(expectedSeverity);
+ expect(results[0].path.join('.')).toBe(
+ 'paths./v1/drinks.get.responses.200.content.application/json.schema.allOf.1.properties.drinks.items'
+ );
+ });
+ });
+});
diff --git a/packages/ruleset/test/is-object-schema.test.js b/packages/ruleset/test/is-object-schema.test.js
index 833a6ae54..b82ddc625 100644
--- a/packages/ruleset/test/is-object-schema.test.js
+++ b/packages/ruleset/test/is-object-schema.test.js
@@ -58,4 +58,34 @@ describe('Utility function: isObjectSchema()', () => {
})
).toBe(true);
});
+
+ it('should recurse through `oneOf` and `allOf` (implicit object type)', async () => {
+ expect(
+ isObjectSchema({
+ oneOf: [
+ {
+ allOf: [{ properties: {} }, {}]
+ },
+ {
+ properties: {}
+ }
+ ]
+ })
+ ).toBe(true);
+ });
+
+ it('should recurse through `allOf` (implicit object type)', async () => {
+ expect(
+ isObjectSchema({
+ allOf: [
+ {
+ allOf: [{ properties: {} }, {}]
+ },
+ {
+ properties: {}
+ }
+ ]
+ })
+ ).toBe(true);
+ });
});
diff --git a/packages/ruleset/test/is-primitive-schema.test.js b/packages/ruleset/test/is-primitive-schema.test.js
new file mode 100644
index 000000000..6f552d3de
--- /dev/null
+++ b/packages/ruleset/test/is-primitive-schema.test.js
@@ -0,0 +1,142 @@
+const { isPrimitiveSchema } = require('../src/utils');
+
+describe('Utility function: isPrimitiveSchema()', () => {
+ it('should return `false` for `undefined`', async () => {
+ expect(isPrimitiveSchema(undefined)).toBe(false);
+ });
+
+ it('should return `false` for `null`', async () => {
+ expect(isPrimitiveSchema(null)).toBe(false);
+ });
+
+ it('should return `false` for an array', async () => {
+ expect(isPrimitiveSchema([])).toBe(false);
+ });
+
+ it('should return `false` for an empty object', async () => {
+ expect(isPrimitiveSchema({})).toBe(false);
+ });
+
+ it('should return `true` for a boolean schema', async () => {
+ expect(isPrimitiveSchema({ type: 'boolean' })).toBe(true);
+ });
+
+ it('should return `true` for a byte schema', async () => {
+ expect(isPrimitiveSchema({ type: 'string', format: 'byte' })).toBe(true);
+ });
+
+ it('should return `true` for a double schema', async () => {
+ expect(isPrimitiveSchema({ type: 'number', format: 'double' })).toBe(true);
+ });
+
+ it('should return `true` for an enumeration', async () => {
+ expect(isPrimitiveSchema({ type: 'string', enum: ['foo', 'bar'] })).toBe(
+ true
+ );
+ });
+
+ it('should return `true` for a float schema', async () => {
+ expect(isPrimitiveSchema({ type: 'number', format: 'float' })).toBe(true);
+ });
+
+ it('should return `true` for a int32 schema', async () => {
+ expect(isPrimitiveSchema({ type: 'integer', format: 'int32' })).toBe(true);
+ });
+
+ it('should return `true` for a int64 schema', async () => {
+ expect(isPrimitiveSchema({ type: 'integer', format: 'int64' })).toBe(true);
+ });
+
+ it('should return `true` for a integer schema', async () => {
+ expect(isPrimitiveSchema({ type: 'integer' })).toBe(true);
+ });
+
+ it('should return `true` for a number schema', async () => {
+ expect(isPrimitiveSchema({ type: 'number' })).toBe(true);
+ });
+
+ it('should return `true` for a string schema', async () => {
+ expect(isPrimitiveSchema({ type: 'string' })).toBe(true);
+ });
+
+ it('should return true for a composed schema that resolves to "int32"', async () => {
+ expect(
+ isPrimitiveSchema({
+ oneOf: [
+ {
+ allOf: [
+ {
+ anyOf: [
+ { type: 'integer', format: 'int32' },
+ { type: 'integer', format: 'int32' }
+ ]
+ },
+ {}
+ ]
+ },
+ { type: 'integer', format: 'int32' }
+ ]
+ })
+ ).toBe(true);
+ });
+
+ it('should return true for a composed schema that resolves to "double"', async () => {
+ expect(
+ isPrimitiveSchema({
+ oneOf: [
+ {
+ allOf: [
+ {
+ anyOf: [
+ { type: 'number', format: 'double' },
+ { type: 'number', format: 'double' }
+ ]
+ },
+ {}
+ ]
+ },
+ { type: 'number', format: 'double' }
+ ]
+ })
+ ).toBe(true);
+ });
+
+ it('should return true for a composed schema that resolves to "number"', async () => {
+ expect(
+ isPrimitiveSchema({
+ oneOf: [
+ {
+ allOf: [{ anyOf: [{ type: 'number' }, { type: 'number' }] }, {}]
+ },
+ { type: 'number' }
+ ]
+ })
+ ).toBe(true);
+ });
+
+ it('should return true for a composed schema that resolves to "boolean"', async () => {
+ expect(
+ isPrimitiveSchema({
+ oneOf: [
+ {
+ allOf: [{ anyOf: [{ type: 'boolean' }, { type: 'boolean' }] }, {}]
+ },
+ { type: 'boolean' }
+ ]
+ })
+ ).toBe(true);
+ });
+
+ it('should return true for a composed schema that resolves to "string"', async () => {
+ expect(
+ isPrimitiveSchema({
+ oneOf: [
+ {
+ allOf: [{ anyOf: [{ type: 'string' }, { type: 'string' }] }, {}]
+ },
+ { type: 'string' }
+ ]
+ })
+ ).toBe(true);
+ });
+});
diff --git a/packages/ruleset/test/merge-allof-schema-properties.test.js b/packages/ruleset/test/merge-allof-schema-properties.test.js
index 1b4f219f0..71179d485 100644
--- a/packages/ruleset/test/merge-allof-schema-properties.test.js
+++ b/packages/ruleset/test/merge-allof-schema-properties.test.js
@@ -15,7 +15,7 @@ describe('Utility function: mergeAllOfSchemaProperties()', () => {
expect(mergeAllOfSchemaProperties(schema)).toStrictEqual(schema);
});
- it('should return original schema (minum allOf) if empty allOf', async () => {
+ it('should return original schema (minus allOf) if empty allOf', async () => {
const schema = {
description: 'the description',
type: 'object',
|