@ibm-cloud/openapi-ruleset
Version:
Spectral ruleset for validating IBM Cloud services
340 lines (310 loc) • 12.9 kB
JavaScript
/**
* Copyright 2017 - 2023 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/
const {
mergeAllOfSchemaProperties,
LoggerFactory,
getOffsetParamIndex,
getPageTokenParamIndex,
getSuccessCode,
getResponseSchema,
getPaginatedOperationFromPath,
} = require('../utils');
const {
isIntegerSchema,
isStringSchema,
} = require('@ibm-cloud/openapi-ruleset-utilities');
let ruleId;
let logger;
module.exports = function (pathObj, _opts, context) {
if (!logger) {
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}
return paginationStyle(pathObj, context.path);
};
/**
* This function implements the pagination-style rule which performs numerous checks
* on a "get" operation that is recognized as a paginated list operation.
* An operation is considered to be a paginated "list"-type operation if:
* 1. The path doesn't end in a path param reference (e.g. /v1/drinks vs /v1/drinks/{drink_id})
* 2. The operation is a "get"
* 3. The operation's response schema is an object containing an array property.
* 4. The operation defines either an "offset" query param or a page token-type query param
* whose name is in ['start', 'token', 'cursor', 'page', 'page_token'].
*
* The specific checks that are performed are identified in the comments below (e.g. "Check #1").
* Along with a description of each check, you'll find links to the API Handbook related to the check.
* Also, there are comments in the corresponding testcase that refer to the various checks
* to make it easy to cross-reference.
*
* @param {*} pathItem the path item that potentially contains a paginated "get" operation.
* @param {*} path the array of path segments indicating the "location" of the pathItem within the API definition
* @returns an array containing the violations found or [] if no violations
*/
function paginationStyle(pathItem, path) {
logger.debug(`${ruleId}: checking pathItem at location: ${path.join('.')}`);
const operation = getPaginatedOperationFromPath(pathItem, path, {
logger,
ruleId,
});
// If `operation` is null, this is not a paginated operation.
if (!operation) {
logger.debug(
`${ruleId}: no paginated operation found at path '${path.join('.')}'`
);
return [];
}
//
// If we made it this far, we know that the operation is at least attempting to
// support pagination, so we'll perform the various checks below.
//
logger.debug(
`${ruleId}: checking paginated operation at path ${path.join('.')}.get`
);
const results = [];
// The actual path string (e.g. '/v1/resources') will be the last element in 'path'.
const pathStr = path[path.length - 1];
// Note: any operation returned from `getPaginatedOperationFromPath`
// is guaranteed to have defined parameters.
const params = operation.parameters;
// Check #1: If the operation has a 'limit' query param, it must be type integer, optional,
// and have default and maximum values.
// References:
// - https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#limit-with-page-token
// - https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#limit-with-offset
const limitParamIndex = params.findIndex(
param => param.name === 'limit' && param.in === 'query'
);
if (limitParamIndex >= 0) {
const limitParam = params[limitParamIndex];
if (
!limitParam.schema ||
!isIntegerSchema(limitParam.schema) ||
!!limitParam.required ||
!limitParam.schema.default ||
!limitParam.schema.maximum
) {
results.push({
message: `The 'limit' parameter must be of type integer and optional with default and maximum values`,
path: [
'paths',
pathStr,
'get',
'parameters',
limitParamIndex.toString(),
],
});
}
}
// Check #2: If the operation has an 'offset' query param, it must be type integer and optional.
// Reference: https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#offset
const offsetParamIndex = getOffsetParamIndex(params);
if (offsetParamIndex >= 0) {
const offsetParam = params[offsetParamIndex];
if (
!offsetParam.schema ||
!isIntegerSchema(offsetParam.schema) ||
!!offsetParam.required
) {
results.push({
message: `The 'offset' parameter must be of type integer and optional`,
path: [
'paths',
pathStr,
'get',
'parameters',
offsetParamIndex.toString(),
],
});
}
}
// Check #3: If the operation has an 'offset' query param, then it must also have a 'limit' query param.
// This is because the presence of the 'offset' param indicates offset/limit pagination.
// Reference: https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#offset-and-limit-pagination
if (offsetParamIndex >= 0 && limitParamIndex < 0) {
results.push({
message: `The operation must define a 'limit' query parameter if the 'offset' query parameter is defined`,
path: ['paths', pathStr, 'get'],
});
}
// Check #4: If the operation has a page token-type query param, then it must be type string and optional,
// and the name should be "start".
// Reference: https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#token-based-pagination
const pageTokenParamIndex = getPageTokenParamIndex(params);
if (pageTokenParamIndex !== -1) {
const pageTokenParam = params[pageTokenParamIndex];
if (pageTokenParam.name !== 'start') {
results.push({
message: `The '${pageTokenParam.name}' parameter should be named 'start'`,
path: [
'paths',
pathStr,
'get',
'parameters',
pageTokenParamIndex.toString(),
],
});
}
if (
!pageTokenParam.schema ||
!isStringSchema(pageTokenParam.schema) ||
!!pageTokenParam.required
) {
results.push({
message: `The '${pageTokenParam.name}' parameter must be of type string and optional`,
path: [
'paths',
pathStr,
'get',
'parameters',
pageTokenParamIndex.toString(),
],
});
}
}
// A valid successful response (with a schema) is guaranteed to be defined by
// `getPaginatedOperationFromPath`.
const successCode = getSuccessCode(operation);
const responseSchema = getResponseSchema(operation.responses[successCode]);
// Pre-defined path that points to the response schema.
// We'll augment this path with additional segments in the checks that
// are performed below.
// Note, however, that the Spectral code that invokes our rule will
// potentially modify the reported path in cases where it can't properly
// "reverse map" the reported path (within the resolved document) back
// to the equivalent path within the unresolved document.
// This would typically occur, for example, if an allOf is used within the
// response schema (which would be typical for a paginated response).
// Nonetheless, we'll report the "correct" path here and our testcases will
// just need to take into account this less-than-perfect reverse-mapping
// performed by the Spectral code.
const responseSchemaPath = [
'paths',
pathStr,
'get',
'responses',
successCode,
'content',
'application/json',
'schema',
];
// Check #5: If the operation defines the "limit" query param, then the response body must also
// contain a "limit" property that is type integer and required.
// References:
// - https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#additional-response-fields-with-token
// - https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#additional-response-fields-with-offset
if (limitParamIndex >= 0) {
const limitProp = responseSchema.properties.limit;
if (!limitProp) {
results.push({
message: `A paginated list operation with a 'limit' query parameter must include a 'limit' property in the response body schema`,
path: responseSchemaPath,
});
} else if (
!isIntegerSchema(limitProp) ||
!responseSchema.required ||
responseSchema.required.indexOf('limit') === -1
) {
results.push({
message: `The 'limit' property in the response body of a paginated list operation must be of type integer and required`,
path: [...responseSchemaPath, 'properties', 'limit'],
});
}
}
// Check #6: If the operation has an "offset" query param, the response body schema
// must also contain an "offset" property that is type integer and required.
// Reference: https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#additional-response-fields-with-offset
if (offsetParamIndex !== -1) {
const offsetProp = responseSchema.properties.offset;
if (!offsetProp) {
results.push({
message: `A paginated list operation with an 'offset' query parameter must include an 'offset' property in the response body schema`,
path: responseSchemaPath,
});
} else if (
!isIntegerSchema(offsetProp) ||
!responseSchema.required ||
responseSchema.required.indexOf('offset') === -1
) {
results.push({
message: `The 'offset' property in the response body of a paginated list operation must be of type integer and required`,
path: [...responseSchemaPath, 'properties', 'offset'],
});
}
}
// Check #7 is implemented in the collection-array-property rule.
// Check #8: If the response body contains a "total_count" property, it must be type integer and required.
// References:
// - https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#additional-response-fields-with-token
// - https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#additional-response-fields-with-offset
const tcProp = responseSchema.properties.total_count;
if (tcProp) {
if (
!isIntegerSchema(tcProp) ||
!responseSchema.required ||
responseSchema.required.indexOf('total_count') === -1
) {
results.push({
message: `The 'total_count' property in the response body of a paginated list operation must be of type integer and required`,
path: [...responseSchemaPath, 'properties', 'total_count'],
});
}
}
// Checks 9-12 below will verify that the response schema contains the
// "first", "last", "previous", and "next" properties which are supposed to
// provide links (in the form of a request url string) to the first/last/previous/next result pages.
// Each of these properties should be an object that contains an "href" string property.
// Reference: https://cloud.ibm.com/docs/api-handbook?topic=api-handbook-pagination#pagination-links
// Check #9: The response body should contain a "first" property which links to the first page of results.
results.push(
...checkPageLink(responseSchemaPath, responseSchema, 'first', true)
);
// Check #10: The response body should contain a "last" property which links to the last page of results.
results.push(
...checkPageLink(responseSchemaPath, responseSchema, 'last', false)
);
// Check #11: The response body should contain a "previous" property which links to the previous page of results.
results.push(
...checkPageLink(responseSchemaPath, responseSchema, 'previous', false)
);
// Check #12: The response body should contain a "next" property which links to the next page of results.
results.push(
...checkPageLink(responseSchemaPath, responseSchema, 'next', true)
);
if (results.length > 0) {
logger.debug(`${ruleId}: Results: ' + ${JSON.stringify(results, null, 2)}`);
} else {
logger.debug(`${ruleId}: PASSED!`);
}
return results;
}
// Verify that the '<name>' property exists within 'responseSchema'
// and represents a valid "page link" value (an object with an "href" field).
// The 'isRequired' flag indicates whether the specified property is required
// to be present or not.
function checkPageLink(path, responseSchema, name, isRequired) {
const results = [];
const pageLinkProp = responseSchema.properties[name];
if (pageLinkProp) {
const pageLinkSchema = mergeAllOfSchemaProperties(pageLinkProp);
if (
!pageLinkSchema ||
!pageLinkSchema.properties ||
!pageLinkSchema.properties.href ||
!isStringSchema(pageLinkSchema.properties.href)
) {
results.push({
message: `The '${name}' property should be an object with an 'href' string property`,
path: [...path, 'properties', name],
});
}
} else if (isRequired) {
results.push({
message: `A paginated list operation should include a '${name}' property in the response body schema`,
path,
});
}
return results;
}