@ibm-cloud/openapi-ruleset
Version:
Spectral ruleset for validating IBM Cloud services
316 lines (273 loc) • 11.6 kB
JavaScript
/**
* Copyright 2024 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/
const {
getExamplesForSchema,
isDateSchema,
isDateTimeSchema,
isIntegerSchema,
isObject,
isStringSchema,
validateNestedSchemas,
} = require('@ibm-cloud/openapi-ruleset-utilities');
const {
LoggerFactory,
isDateBasedName,
isDateBasedValue,
isParamContentSchema,
isParamSchema,
isRequestBodySchema,
isResponseSchema,
isSchemaProperty,
} = require('../utils');
let ruleId;
let logger;
/**
* The implementation for this rule makes assumptions that are dependent on the
* presence of the following other rules:
*
* - oas3-valid-media-example
* - oas3-valid-schema-example
*
* These rules verify that the correct, specific format (date vs date-time) is
* used for schemas based on their example value. So, we aren't as specific
* with that check in this rule - we recommend either "date" or "date-time".
*/
module.exports = function (schema, _opts, context) {
if (!logger) {
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}
return checkForDateBasedFormat(
schema,
context.path,
context.documentInventory.resolved
);
};
/**
* This function implements a rule that enforces date-based schemas use either
* the "date" or "date-time" format, so that they're accurately documented as
* date-based logical types. We use a heuristic based on either the name of a
* schema (derived from the property name, if the schema is a property schema)
* or the example value provided for a given schema or schema property.
*
* The logic here recursively checks all schemas for the presence of unmarked
* date-based schemas. As it traverses the schemas, it compiles a list of
* potentially-relevant example values. This way, if an object schema defines
* its own example, which includes a value for a nested property that should
* be identified by the rule, we can track down the value once we reach the
* schema for said property. The logic will also gather any relevant parameter
* or media type examples that may be defined outside of the schema path.
*
* @param {object} s the schema to check
* @param {array} p the array of path segments indicating the "location" of the schema within the API definition
* @param {object} apidef the resolved API definition
* @returns an array containing the violations found or [] if no violations
*/
function checkForDateBasedFormat(s, p, apidef) {
// Map connecting a list of examples for a schema to its logical path.
const examples = {};
// Check for any examples outside of the schema path - they may be in
// request bodies, response bodies, or parameters. Store these separately.
const indirectExamples = checkForIndirectExamples(p, apidef);
return validateNestedSchemas(s, p, (schema, path, logicalPath) => {
logger.debug(`${ruleId}: checking schema at location: ${path.join('.')}`);
logger.debug(
`${ruleId}: logical schema path is : ${logicalPathForLogger(logicalPath)}`
);
// Use a composition-aware utility to gather any examples relevant to this
// schema, including those defined on applicator schemas in oneOf, etc.
const schemaExamples = getExamplesForSchema(schema);
logger.debug(`${ruleId}: ${schemaExamples.length} examples found`);
// Examples have already been stored on the parent - as we go through the
// schemas, check for the presence of an example value for the current
// property within the parent's example.
const parentalExamples =
// If the logical path is empty, there are no parents, but there may be
// indirect examples to add in the ":" branch.
logicalPath.length > 0
? // Look at the example values for the logical parent schema, if any.
// For successive, nested properties, this will end up propagating
// examples through the recursive descent so that example values
// separated from a property by multiple degrees of nesting will
// still be preserved.
examples[logicalPath.slice(0, -1).join('.')]
.map(e => {
// Check the parental example values for
// the presence of the current property.
const prop = logicalPath.at(-1);
// Check for sentinel indicating an array.
if (prop === '[]' && Array.isArray(e)) {
return e;
}
// Check for sentinel indicating a dictionary.
if (prop === '*' && isObject(e)) {
return Object.values(e);
}
// Standard model path. Wrap value in an array to match list and
// dictionary behavior - it will be flattened out later.
return [e[prop]];
})
// Each example may map to multiple examples - flatten the result
// of the mapping to include all relevant examples in the list.
.flat()
// We are not guaranteed to find a value - filter
// out any values that are not defined.
.filter(e => e !== undefined)
: // Add indirect examples to the map - note that they will necessarily
// be indexed with the empty string key, like primary schemas.
indirectExamples;
logger.debug(
`${ruleId}: ${parentalExamples.length} examples found in logical parent`
);
// Index the examples with the stringified logical path.
// Note that the unconditional assignment is intentional - there may be
// existing entries for this same logical path (e.g. for the same
// nested property within a different oneOf sibling) but we want to
// override them, always. Otherwise, the behavior would depend on the
// order the schemas are checked in (the logic may look at more examples
// for one instance of a property than another, arbitrarily) and we don't
// make a guarantee that order will be stable in `validateNestedSchemas`.
examples[logicalPath.join('.')] = [
...schemaExamples,
...parentalExamples,
].filter(e => e !== null); // Null values are technically allowed - don't keep them.
// Perform the validation using the first value example value found for the
// schema at this logical path.
const exampleValue = examples[logicalPath.join('.')].find(
e => e !== undefined
);
return performValidation(schema, path, exampleValue);
});
}
// This function performs the actual checks against a schema to determine if
// it should be a "date" or "date-time" schema, but isn't defined as one.
// It is wrapped in the outer function for the gathering of examples, etc. but
// this function implements the checks: 1) see if the name of a property
// indicates that it is a date-based schema and 2) see if the example value for
// a schema indicates that it is a date-based schema.
function performValidation(schema, path, exampleValue) {
// If this is already a date or date-time schema, no need to check if it should be.
if (isDateSchema(schema) || isDateTimeSchema(schema)) {
logger.debug(
`${ruleId}: skipping date-based schema at location: ${path.join('.')}`
);
return [];
}
// Check if this is a schema property
if (isSchemaProperty(path)) {
logger.debug(`${ruleId}: detected named property at "${path.join('.')}"`);
// Check for a name that would indicate the property should be date-based
if (isDateBasedName(path.at(-1))) {
logger.debug(
`${ruleId}: property name at "${path.join('.')}" is date-based`
);
// We only assume a property could be a date-time value if it's a string or integer
if (isStringSchema(schema) || isIntegerSchema(schema)) {
logger.debug(
`${ruleId}: date-based property name at "${path.join(
'.'
)}" is a string or integer`
);
// If the schema is determined to be a date-time schema by the name alone,
// we can return - no need to look for an example value.
return [
{
message:
'According to its name, this property should use type "string" and format "date" or "date-time"',
path,
},
];
}
}
}
// Check example values for string schemas.
if (isStringSchema(schema)) {
if (exampleValue !== undefined) {
logger.debug(`${ruleId}: example value found: ${exampleValue}`);
if (isDateBasedValue(exampleValue)) {
return [
{
message:
'According to its example value, this schema should use type "string" and format "date" or "date-time"',
path,
},
];
}
} else {
logger.debug(`${ruleId}: no example value found`);
}
}
return [];
}
// This function takes an object, as well as a path to a specific value, and
// parses the object, looking for the value at that path. If it finds one,
// the value will be returned. If not, the function will return `undefined`.
function getObjectValueAtPath(obj, pathToValue) {
return pathToValue.reduce((value, field) => value?.[field], obj);
}
// "Indirect" examples are those coming from request bodies, response bodies, and parameters.
function checkForIndirectExamples(path, apidef) {
logger.debug(
`${ruleId}: checking indirect examples for schema at location: ${path.join(
'.'
)}`
);
// Parameter and Media Type objects have the same format when it comes
// to examples, so we can treat all of these scenarios the same way.
if (
isRequestBodySchema(path) ||
isResponseSchema(path) ||
isParamSchema(path) ||
isParamContentSchema(path)
) {
// Example fields would be siblings of the schema we're looking at, so we need to look in the API
// for the path, minus the last value (which is "schema").
const examples = getOpenApiExamples(
getObjectValueAtPath(apidef, path.slice(0, -1))
);
// Check for the special case of looking at a content schema for a parameter that
// itself defines an example (pull the last three values off the path to check).
if (isParamContentSchema(path)) {
examples.push(
...getOpenApiExamples(getObjectValueAtPath(apidef, path.slice(0, -3)))
);
}
logger.debug(
`${ruleId}: ${
examples.length
} indirect examples found for schema at location: ${path.join('.')}`
);
// Put the examples in the format the downstream algorithm for this rule needs.
return examples;
}
return [];
}
// OpenAPI defines its own example structure, separate from schema examples,
// on Parameter and Media Type objects. Use this function to parse those
// structures and return any relevant examples. The argument may be either a
// Parameter or Media Type object and will return a list.
function getOpenApiExamples(artifact) {
if (!isObject(artifact)) {
return [];
}
// The `example` and `examples` fields are mutually exclusive.
if (artifact.example) {
return [artifact.example];
}
// This will be a map, potentially containing multiple examples. Return all of them.
if (artifact.examples) {
return Object.values(artifact.examples).map(
exampleObject => exampleObject.value
);
}
return [];
}
// Format the logical path in a way that makes sense when the array is empty.
function logicalPathForLogger(logicalPath) {
if (!logicalPath.length) {
return `'' (primary schema)`;
}
return `'${logicalPath.join('.')}'`;
}