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 oas2, oas3 +composed-schema-restrictions +warn +Verifies that schema compositions involving oneOf and anyOf comply +with best practices associated with SDK generation. +oas3 + + 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: 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',
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.