@ibm-cloud/openapi-ruleset
Version:
Spectral ruleset for validating IBM Cloud services
335 lines (295 loc) • 11.5 kB
JavaScript
/**
* Copyright 2023 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/
const {
schemaHasProperty,
getNodes,
} = require('@ibm-cloud/openapi-ruleset-utilities');
const {
LoggerFactory,
computeRefsAtPaths,
getCanonicalSchemaForPath,
getRequestBodySchemaForOperation,
getResourceOrientedPaths,
getSchemaNameAtPath,
getSuccessResponseSchemaForOperation,
} = 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-request-and-response-content: all request bodies and relevant success
* responses define content objects
*
* - ibm-content-contains-schema: the content objects define schemas
*
* - ibm-avoid-inline-schemas: all schemas are named (defined with references)
*
* - ibm-collection-array-property: the presence and correct name of the
* property in a collection schema that holds the resource list
*
* - ibm-schema-casing-convention: schema names use upper camel case
*/
module.exports = function schemaNames(apidef, options, context) {
if (!logger) {
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}
return checkSchemaNames(apidef, getNodes(context));
};
/**
* This function checks for most of the API Handbook's schema naming conventions.
* For each pair of resource-oriented paths, it determines the name of the
* canonical schema, then checks the name of the collection schema, collection
* schema resource list property items schema, creation schemas, and patch schema
* against the canonical schema to ensure they are appropriately named.
*
* The conventions we are unable to cover in this rule are:
* - The name of the canonical schema relative to the path segments (pluralization is
* too difficult of a problem to solve for in a robust way)
* - The names of Identity or Reference schemas (no solid heuristic for determining these)
*
* @param {*} apidef the entire, resolved API definition as an object
* @param {*} nodes the spectral-computed graph nodes mapping paths to referenced schemas
* @returns an array containing the violations found or [] if no violations
*/
function checkSchemaNames(apidef, nodes) {
const pathToReferencesMap = computeRefsAtPaths(nodes);
const resourceOrientedPaths = getResourceOrientedPaths(apidef);
if (Object.keys(resourceOrientedPaths).length === 0) {
logger.debug(`${ruleId}: no resource-oriented paths found, skipping rule`);
}
const errors = [];
for (const [genericPath, specificPath] of Object.entries(
resourceOrientedPaths
)) {
logger.debug(
`${ruleId}: found resource path pair: "${genericPath}" and "${specificPath}"`
);
const { canonicalSchemaName } = getCanonicalSchemaForPath(
specificPath,
apidef,
pathToReferencesMap,
{
logger,
ruleId,
}
);
// If we can't find the canonical schema,
// don't perform the rest of the checks.
if (!canonicalSchemaName) {
continue;
}
// 2. Collection schemas
const collectionSchemaInfo = getSuccessResponseSchemaForOperation(
apidef.paths[genericPath].get,
`paths.${genericPath}.get`
);
const collectionSchemaPath = collectionSchemaInfo.schemaPath;
logger.debug(
`${ruleId}: found the path to the collection schema to be ${collectionSchemaPath}`
);
if (collectionSchemaPath) {
const collectionSchemaName = getSchemaNameAtPath(
collectionSchemaPath,
pathToReferencesMap
);
logger.debug(
`${ruleId}: found the name of the collection schema to be ${collectionSchemaName}`
);
// 2a) Check the name of the collection schema itself
if (
collectionSchemaName &&
collectionSchemaName !== `${canonicalSchemaName}Collection`
) {
logger.debug(
`${ruleId}: error! collection schema does not follow conventions`
);
errors.push({
message: `Collection schema for path '${genericPath}' should use the name: ${canonicalSchemaName}Collection`,
path: collectionSchemaPath.split('.'),
});
}
// Locate the actual schema in order to check its list property.
const collectionSchema = collectionSchemaPath
.split('.')
.reduce(
(openApiArtifact, pathElement) => openApiArtifact[pathElement],
apidef
);
if (collectionSchema) {
logger.debug(`${ruleId}: found collection schema object`);
// The name of the array property should match the last segment of the
// generic path. We already check for this in `ibm-collection-array-property`.
const resourceListPropName = genericPath.split('/').at(-1);
logger.debug(
`${ruleId}: expecting the resource list property to be called ${resourceListPropName}`
);
if (schemaHasProperty(collectionSchema, resourceListPropName)) {
logger.debug(
`${ruleId}: found resource list property named ${resourceListPropName}`
);
const resourceListSchemaPath = computePathToProperty(
resourceListPropName,
collectionSchemaPath,
collectionSchema
);
logger.debug(
`${ruleId}: found the path to the resource list property to be ${resourceListSchemaPath}`
);
const listPropItemsSchemaPath = `${resourceListSchemaPath}.items`;
logger.debug(
`${ruleId}: found the path to the resource list property items to be ${listPropItemsSchemaPath}`
);
const listPropItemsSchemaName = getSchemaNameAtPath(
listPropItemsSchemaPath,
pathToReferencesMap
);
logger.debug(
`${ruleId}: found the name of the resource list property items schema to be ${listPropItemsSchemaName}`
);
if (
listPropItemsSchemaName &&
listPropItemsSchemaName !== canonicalSchemaName &&
listPropItemsSchemaName !== `${canonicalSchemaName}Summary`
) {
logger.debug(
`${ruleId}: reporting error! list prop in collection is wrong`
);
errors.push({
message: `Items schema for collection resource list property '${resourceListPropName}' should use the name: ${canonicalSchemaName} or ${canonicalSchemaName}Summary`,
path: listPropItemsSchemaPath.split('.'),
});
}
}
}
}
// 3. Prototype schema
// 3a) post
const postRequestSchemaInfo = getRequestBodySchemaForOperation(
apidef.paths[genericPath].post,
`paths.${genericPath}.post`
);
const postRequestSchemaPath = postRequestSchemaInfo.schemaPath;
logger.debug(
`${ruleId}: found the path to the prototype schema (for post) to be ${postRequestSchemaPath}`
);
if (postRequestSchemaPath) {
const postRequestSchemaName = getSchemaNameAtPath(
postRequestSchemaPath,
pathToReferencesMap
);
logger.debug(
`${ruleId}: found the name of the prototype schema (for post) to be ${postRequestSchemaName}`
);
if (
postRequestSchemaName &&
postRequestSchemaName !== `${canonicalSchemaName}Prototype` &&
// The API Handbook makes an exception for cases where the canonical
// schema itself should be used for creation
postRequestSchemaName !== canonicalSchemaName
) {
logger.debug(`${ruleId}: reporting error! post prototype is wrong`);
errors.push({
message: `Prototype schema (POST request body) for path '${genericPath}' should use the name: ${canonicalSchemaName}Prototype`,
path: postRequestSchemaPath.split('.'),
});
}
}
// 3a) put
const putRequestSchemaInfo = getRequestBodySchemaForOperation(
apidef.paths[specificPath].put,
`paths.${specificPath}.put`
);
const putRequestSchemaPath = putRequestSchemaInfo.schemaPath;
logger.debug(
`${ruleId}: found the path to the prototype schema (for put) to be ${putRequestSchemaPath}`
);
if (putRequestSchemaPath) {
const putRequestSchemaName = getSchemaNameAtPath(
putRequestSchemaPath,
pathToReferencesMap
);
logger.debug(
`${ruleId}: found the name of the prototype schema (for put) to be ${putRequestSchemaName}`
);
if (
putRequestSchemaName &&
putRequestSchemaName !== `${canonicalSchemaName}Prototype` &&
// The API Handbook makes an exception for cases where the canonical
// schema itself should be used for creation
putRequestSchemaName !== canonicalSchemaName
) {
logger.debug(`${ruleId}: reporting error! put prototype is wrong`);
errors.push({
message: `Prototype schema (PUT request body) for path '${specificPath}' should use the name: ${canonicalSchemaName}Prototype`,
path: putRequestSchemaPath.split('.'),
});
}
}
// 4. Patch schema
const patchRequestSchemaInfo = getRequestBodySchemaForOperation(
apidef.paths[specificPath].patch,
`paths.${specificPath}.patch`
);
const patchRequestSchemaPath = patchRequestSchemaInfo.schemaPath;
logger.debug(
`${ruleId}: found the path to the patch schema to be ${patchRequestSchemaPath}`
);
if (patchRequestSchemaPath) {
const patchRequestSchemaName = getSchemaNameAtPath(
patchRequestSchemaPath,
pathToReferencesMap
);
logger.debug(
`${ruleId}: found the name of the patch schema to be ${patchRequestSchemaName}`
);
if (
patchRequestSchemaName &&
patchRequestSchemaName !== `${canonicalSchemaName}Patch`
) {
logger.debug(`${ruleId}: reporting error! patch schema is wrong`);
errors.push({
message: `Patch schema for path '${specificPath}' should use the name: ${canonicalSchemaName}Patch`,
path: patchRequestSchemaPath.split('.'),
});
}
}
}
return errors;
}
/**
* Given a property name, a path to a given schema, and that schema object,
* compute the path to the property definition while handling the potential for
* composed models (and nested composed models). Returns the first instance it
* finds.
*
* @param {string} name - the name of the property to look for
* @param {string} path - the resolved JSON path to the schema, as a dot-separated string
* @param {object} schema - the resolved schema object
* @returns {string} - the resolved JSON path to the property, as a dot-separated string,
* or `undefined` if the property is not found.
*/
function computePathToProperty(name, path, schema) {
if (schema.properties && schema.properties[name]) {
return `${path}.properties.${name}`;
}
for (const applicator of ['allOf', 'oneOf', 'anyOf']) {
if (Array.isArray(schema[applicator])) {
for (let i = 0; i < schema[applicator].length; i++) {
const subschema = schema[applicator][i];
const checkPath = computePathToProperty(
name,
`${path}.${applicator}.${i.toString()}`,
subschema
);
if (checkPath && checkPath.endsWith(`properties.${name}`)) {
return checkPath;
}
}
}
}
}