Skip to content

Commit

Permalink
feat(new-rule): ibm-accept-and-return-models (#680)
Browse files Browse the repository at this point in the history
This commit introduces a new rule, `ibm-accept-and-return-models`, which enforces
Handbook guidance requiring request and response bodies to be represented as
"models". It does so by enforcing that a JSON request or response body defines
concrete fields through the `properties` attribute of its schema.

Signed-off-by: Dustin Popp <[email protected]>
  • Loading branch information
dpopp07 authored Aug 30, 2024
1 parent cb18002 commit 01e9881
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 23 deletions.
65 changes: 65 additions & 0 deletions docs/ibm-cloud-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ which is delivered in the `@ibm-cloud/openapi-ruleset` NPM package.
+ [Define a new rule](#define-a-new-rule)
* [Spectral Overrides](#spectral-overrides)
- [Reference](#reference)
* [ibm-accept-and-return-models](#ibm-accept-and-return-models)
* [ibm-anchored-patterns](#ibm-anchored-patterns)
* [ibm-api-symmetry](#ibm-api-symmetry)
* [ibm-array-attributes](#ibm-array-attributes)
Expand Down Expand Up @@ -131,6 +132,12 @@ is provided in the [Reference](#reference) section below.
<th>Rule Id</th><th>Severity</th><th>Description</th><th>OpenAPI Versions</th>
</tr>
<tr>
<td><a href="#ibm-accept-and-return-models">ibm-accept-and-return-models</a></td>
<td>error</td>
<td>Request and response bodies must be defined as model instances.</td>
<td>oas3</td>
</tr>
<tr>
<td><a href="#ibm-anchored-patterns">ibm-anchored-patterns</a></td>
<td>warn</td>
<td>Schema "pattern" attributes should contain regular expressions that include start-of-line and end-of-line anchors (i.e. <code>^</code> and <code>$</code> characters).</td>
Expand Down Expand Up @@ -837,6 +844,64 @@ For details on how to add overrides to your custom ruleset, please read the
This section provides reference documentation about the IBM Cloud Validation Rules contained
in the `@ibm-cloud/openapi-ruleset` package.

### ibm-accept-and-return-models
<table>
<tr>
<td><b>Rule id:</b></td>
<td><b>ibm-accept-and-return-models</b></td>
</tr>
<tr>
<td valign=top><b>Description:</b></td>
<td>
The <a href="https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-models">IBM Cloud API Handbook model guidance</a> states that "Every object returned or accepted by a service MUST be an instance of a model." A model is an object that defines concrete fields (and in this way, is distinct from a dictionary). This rule ensures that each JSON request and response body (accepted and returned objects, respectively) is a model instance by requiring that a schema defines concrete fields via the <code>properties</code> attribute.
</td>
</tr>
<tr>
<td><b>Severity:</b></td>
<td>error</td>
</tr>
<tr>
<td><b>OAS Versions:</b></td>
<td>oas3</td>
</tr>
<tr>
<td valign=top><b>Non-compliant example:<b></td>
<td>
<pre>
requestBody:
content:
'application/json':
schema:
type: object
description: A dictionary-based request body, that should be a model
additionalProperties:
type: string
</pre>
</td>
</tr>
<tr>
<td valign=top><b>Compliant example:</b></td>
<td>
<pre>
requestBody:
content:
'application/json':
schema:
# Note that this should be defined through a "reference" to a
# named schema, using an inline schema for display purposes.
type: object
description: A model of a "Person" resource
properties:
name:
type: string
description: Name of the person
age:
type: integer
description: Age of the person, in years
</pre>
</td>
</tr>
</table>

### ibm-anchored-patterns
<table>
Expand Down
76 changes: 76 additions & 0 deletions packages/ruleset/src/functions/accept-and-return-models.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Copyright 2024 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/

const {
isObject,
schemaHasConstraint,
} = require('@ibm-cloud/openapi-ruleset-utilities');
const { supportsJsonContent, LoggerFactory } = require('../utils');

let ruleId;
let logger;

/**
* The implementation for this rule makes assumptions that are dependent on the
* presence of the following other rules:
*
* ibm-operation-responses - all operations define a response
* ibm-requestbody-is-object - JSON request bodies are object schemas
* ibm-request-and-response content - request and response bodies define content
* ibm-well-defined-dictionaries - additional properties aren't mixed with static properties
*
*/

module.exports = function acceptAndReturnModels(operation, options, context) {
if (!logger) {
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}
return checkForProperties(operation, context.path);
};

/**
* This function checks to ensure a request or response body schema
* contains statically defined properties (i.e. is a model and not
* a dictionary).
*
* @param {*} schema - request or response body schema
* @param {*} path - path to current openapi artifact, as a list
* @returns an array containing the violations found or [] if no violations
*/
function checkForProperties(schema, path) {
logger.debug(`${ruleId}: checking schema at location: ${path.join('.')}`);

// Content that does not use JSON representation is exempt.
if (!supportsJsonContent(path.at(-2))) {
logger.debug(
`${ruleId}: skipping non-JSON schema at location: ${path.join('.')}`
);
return [];
}

if (!schemaHasConstraint(schema, s => schemaDefinesProperties(s))) {
logger.debug(
`${ruleId}: No properties found in schema at location: ${path.join('.')}`
);
return [
{
message:
'Request and response bodies must include fields defined in `properties`',
path,
},
];
}

logger.debug(`${ruleId}: schema at location: ${path.join('.')} passed!`);
}

function schemaDefinesProperties(s) {
return (
s.properties &&
isObject(s.properties) &&
Object.entries(s.properties).length > 0
);
}
1 change: 1 addition & 0 deletions packages/ruleset/src/functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

module.exports = {
acceptAndReturnModels: require('./accept-and-return-models'),
allowedKeywords: require('./allowed-keywords'),
anchoredPatterns: require('./anchored-patterns'),
apiSymmetry: require('./api-symmetry'),
Expand Down
1 change: 1 addition & 0 deletions packages/ruleset/src/ibm-oas.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ module.exports = {
'array-items': 'off',

// IBM Custom Rules
'ibm-accept-and-return-models': ibmRules.acceptAndReturnModels,
'ibm-anchored-patterns': ibmRules.anchoredPatterns,
'ibm-api-symmetry': ibmRules.apiSymmetry,
'ibm-array-attributes': ibmRules.arrayAttributes,
Expand Down
23 changes: 23 additions & 0 deletions packages/ruleset/src/rules/accept-and-return-models.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Copyright 2024 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/

const {
responseSchemas,
requestBodySchemas,
} = require('@ibm-cloud/openapi-ruleset-utilities/src/collections');
const { oas3 } = require('@stoplight/spectral-formats');
const { acceptAndReturnModels } = require('../functions');

module.exports = {
description: 'Request and response bodies must be defined as model instances',
given: [...responseSchemas, ...requestBodySchemas],
message: '{{error}}',
severity: 'error',
formats: [oas3],
resolved: true,
then: {
function: acceptAndReturnModels,
},
};
3 changes: 2 additions & 1 deletion packages/ruleset/src/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
*/

module.exports = {
apiSymmetry: require('./api-symmetry'),
acceptAndReturnModels: require('./accept-and-return-models'),
acceptHeader: require('./accept-header'),
anchoredPatterns: require('./anchored-patterns'),
apiSymmetry: require('./api-symmetry'),
arrayAttributes: require('./array-attributes'),
arrayOfArrays: require('./array-of-arrays'),
arrayResponses: require('./array-responses'),
Expand Down
15 changes: 3 additions & 12 deletions packages/ruleset/src/utils/is-requestbody-exploded.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
const { isArraySchema } = require('@ibm-cloud/openapi-ruleset-utilities');
const {
isJsonMimeType,
isJsonPatchMimeType,
isMergePatchMimeType,
} = require('./mimetype-utils');
const { supportsJsonContent } = require('./mimetype-utils');

/**
* Returns true iff the specified requestBody defines JSON content that will be exploded by
Expand All @@ -30,15 +26,10 @@ function isRequestBodyExploded(requestBody) {
// so let's check if the body will be exploded.

// Does the operation support JSON content?
const jsonMimeType = mimeTypes.find(
m => isJsonMimeType(m) || isJsonPatchMimeType(m) || isMergePatchMimeType(m)
);
const jsonMimeType = mimeTypes.find(m => supportsJsonContent(m));

// Does the operation support non-JSON content?
const hasNonJsonContent = mimeTypes.find(
m =>
!isJsonMimeType(m) && !isJsonPatchMimeType(m) && !isMergePatchMimeType(m)
);
const hasNonJsonContent = mimeTypes.find(m => !supportsJsonContent(m));

// Grab the requestBody schema for the JSON mimetype (if present).
const bodySchema = jsonMimeType ? content[jsonMimeType].schema : null;
Expand Down
14 changes: 13 additions & 1 deletion packages/ruleset/src/utils/mimetype-utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2017 - 2023 IBM Corporation.
* Copyright 2017 - 2024 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/

Expand Down Expand Up @@ -71,9 +71,21 @@ function isFormMimeType(mimeType) {
return !!formMimeTypeREs.find(re => re.test(mimeType));
}

/**
* Returns true if and only if mime-type "m" meets the criteria for
* at least one of the content types that use JSON representation:
* JSON, JSON Patch, or Merge Patch.
* @param {string} m the mime-type string
* @returns boolean true if "mimeType" indicates JSON-represented content
*/
function supportsJsonContent(m) {
return isJsonMimeType(m) || isJsonPatchMimeType(m) || isMergePatchMimeType(m);
}

module.exports = {
isFormMimeType,
isJsonMimeType,
isJsonPatchMimeType,
isMergePatchMimeType,
supportsJsonContent,
};
119 changes: 119 additions & 0 deletions packages/ruleset/test/accept-and-return-models.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Copyright 2024 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/

const { acceptAndReturnModels } = require('../src/rules');
const { makeCopy, rootDocument, testRule, severityCodes } = require('./utils');

const rule = acceptAndReturnModels;
const ruleId = 'ibm-accept-and-return-models';
const expectedSeverity = severityCodes.error;
const expectedMessage =
'Request and response bodies must include fields defined in `properties`';

// To enable debug logging in the rule function, copy this statement to an it() block:
// LoggerFactory.getInstance().addLoggerSetting(ruleId, 'debug');
// and uncomment this import statement:
// const LoggerFactory = require('../src/utils/logger-factory');

describe(`Spectral rule: ${ruleId}`, () => {
describe('Should not yield errors', () => {
it('Clean spec', async () => {
const results = await testRule(ruleId, rule, rootDocument);
expect(results).toHaveLength(0);
});

// non-JSON content, captured in root doc but might as well be explicit !!!
it('Content is non-JSON', async () => {
const testDocument = makeCopy(rootDocument);

testDocument.paths['/v1/movies'].post.requestBody.content = {
'text/plain': {
schema: {
type: 'string',
},
},
};

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

describe('Should yield errors', () => {
describe('Request bodies', () => {
it('Schema has no defined properties - empty', async () => {
const testDocument = makeCopy(rootDocument);

testDocument.components.schemas.MoviePrototype = {
type: 'object',
};

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

const expectedPaths = [
'paths./v1/movies.post.requestBody.content.application/json.schema',
'paths./v1/movies/{movie_id}.put.requestBody.content.application/json.schema',
];

for (let i = 0; i < results.length; i++) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toBe(expectedMessage);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});

it('Schema has no defined properties - only additional properties', async () => {
const testDocument = makeCopy(rootDocument);

testDocument.components.schemas.MoviePrototype = {
type: 'object',
additionalProperties: {
type: 'string',
},
};

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

const expectedPaths = [
'paths./v1/movies.post.requestBody.content.application/json.schema',
'paths./v1/movies/{movie_id}.put.requestBody.content.application/json.schema',
];

for (let i = 0; i < results.length; i++) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toBe(expectedMessage);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});

it('Schema has no defined properties - properties entry is empty', async () => {
const testDocument = makeCopy(rootDocument);

testDocument.components.schemas.CarPatch = {
type: 'object',
properties: {},
};

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

const expectedPaths = [
'paths./v1/cars/{car_id}.patch.requestBody.content.application/merge-patch+json; charset=utf-8.schema',
];

for (let i = 0; i < results.length; i++) {
expect(results[i].code).toBe(ruleId);
expect(results[i].message).toBe(expectedMessage);
expect(results[i].severity).toBe(expectedSeverity);
expect(results[i].path.join('.')).toBe(expectedPaths[i]);
}
});
});
});
});
Loading

0 comments on commit 01e9881

Please sign in to comment.