diff --git a/eslint.config.mjs b/eslint.config.mjs index 130816c0e3..84ed46263c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,14 @@ export default [ ecmaVersion: 2022, sourceType: 'module', }, + rules: { + 'no-extra-boolean-cast': [ + 'warn', + { + enforceForLogicalOperands: true, + }, + ], + }, }, { ignores: ['node-modules'], diff --git a/tools/spectral/ipa/__tests__/IPA110CollectionsRequestIncludeCountNotRequired.test.js b/tools/spectral/ipa/__tests__/IPA110CollectionsRequestIncludeCountNotRequired.test.js new file mode 100644 index 0000000000..9621d7102a --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA110CollectionsRequestIncludeCountNotRequired.test.js @@ -0,0 +1,188 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const parameters = { + parameters: { + includeCount: { + name: 'includeCount', + in: 'query', + schema: { + type: 'boolean', + default: true, + }, + }, + itemsPerPage: { + name: 'itemsPerPage', + in: 'query', + schema: { + type: 'integer', + default: 100, + }, + }, + }, +}; + +testRule('xgen-IPA-110-collections-request-includeCount-not-required', [ + { + name: 'valid', + document: { + paths: { + '/resources': { + get: { + parameters: [ + { + name: 'includeCount', + in: 'query', + schema: { + type: 'boolean', + }, + }, + ], + }, + }, + 'resources/{resourceId}': { + get: {}, + }, + '/resourcesTwo': { + get: { + parameters: [ + { + $ref: '#/components/parameters/includeCount', + }, + ], + }, + }, + 'resourcesTwo/{resourceId}': { + get: {}, + }, + }, + components: parameters, + }, + errors: [], + }, + { + name: 'valid - includeCount not present', + document: { + paths: { + '/resources': { + get: { + parameters: [ + { + $ref: '#/components/parameters/itemsPerPage', + }, + ], + }, + }, + 'resources/{resourceId}': { + get: {}, + }, + }, + components: parameters, + }, + errors: [], + }, + { + name: 'valid - no parameters at all', + document: { + paths: { + '/resources': { + get: {}, + }, + 'resources/{resourceId}': { + get: {}, + }, + }, + }, + errors: [], + }, + { + name: 'invalid - includeCount is required', + document: { + paths: { + '/resources': { + get: { + parameters: [ + { + name: 'includeCount', + in: 'query', + required: true, + schema: { + type: 'boolean', + }, + }, + ], + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-110-collections-request-includeCount-not-required', + message: 'includeCount query parameter of List method must not be required.', + path: ['paths', '/resources', 'get'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid - referenced includeCount is required', + document: { + paths: { + '/resources': { + get: { + parameters: [ + { + $ref: '#/components/parameters/RequiredIncludeCount', + }, + ], + }, + }, + }, + components: { + parameters: { + RequiredIncludeCount: { + name: 'includeCount', + in: 'query', + required: true, + schema: { + type: 'boolean', + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-110-collections-request-includeCount-not-required', + message: 'includeCount query parameter of List method must not be required.', + path: ['paths', '/resources', 'get'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'valid - handles exception', + document: { + paths: { + '/resources': { + get: { + parameters: [ + { + name: 'includeCount', + in: 'query', + required: true, + schema: { + type: 'boolean', + }, + }, + ], + 'x-xgen-IPA-exception': { + 'xgen-IPA-110-collections-request-includeCount-not-required': 'Reason', + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA110CollectionsResponseDefineLinksArray.test.js b/tools/spectral/ipa/__tests__/IPA110CollectionsResponseDefineLinksArray.test.js new file mode 100644 index 0000000000..17f41cff77 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA110CollectionsResponseDefineLinksArray.test.js @@ -0,0 +1,199 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const componentSchemas = { + Resource: { + type: 'object', + }, + PaginatedResourceList: { + type: 'object', + properties: { + totalCount: { + type: 'integer', + }, + results: { + type: 'array', + items: { + $ref: '#/components/schemas/Resource', + }, + }, + links: { + type: 'array', + }, + }, + }, +}; + +testRule('xgen-IPA-110-collections-response-define-links-array', [ + { + name: 'valid schema', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/PaginatedResourceList', + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Resource', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: componentSchemas, + }, + }, + errors: [], + }, + { + name: 'invalid schema missing links', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/PaginatedMissingLinks', + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: { + PaginatedMissingLinks: { + type: 'object', + properties: { + totalCount: { + type: 'integer', + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-110-collections-response-define-links-array', + message: + 'The response for collections should define a links array field, providing links to next and previous pages.', + path: ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'valid inline schema instead of reference', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + totalCount: { + type: 'integer', + }, + results: { + type: 'array', + items: { + $ref: '#/components/schemas/Resource', + }, + }, + links: { + type: 'array', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: componentSchemas, + }, + }, + errors: [], + }, + { + name: 'invalid schema missing results with exceptions', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/PaginatedMissingLinks', + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-110-collections-response-define-links-array': 'Reason', + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: { + PaginatedMissingLinks: { + type: 'object', + properties: { + totalCount: { + type: 'integer', + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/rulesets/IPA-110.yaml b/tools/spectral/ipa/rulesets/IPA-110.yaml index 6007a703b3..22702e9995 100644 --- a/tools/spectral/ipa/rulesets/IPA-110.yaml +++ b/tools/spectral/ipa/rulesets/IPA-110.yaml @@ -6,6 +6,8 @@ functions: - IPA110CollectionsResponseDefineResultsArray - IPA110CollectionsRequestHasItemsPerPageQueryParam - IPA110CollectionsRequestHasPageNumQueryParam + - IPA110CollectionsRequestIncludeCountNotRequired + - IPA110CollectionsResponseDefineLinksArray rules: xgen-IPA-110-collections-use-paginated-prefix: @@ -71,3 +73,31 @@ rules: given: '$.paths[*].get' then: function: 'IPA110CollectionsRequestHasPageNumQueryParam' + xgen-IPA-110-collections-request-includeCount-not-required: + description: | + If the request supports an includeCount query parameter, it must not be required. + + ##### Implementation details + Rule checks for the following conditions: + - Only applies to List methods (GET on resource collection paths) + - Checks if includeCount query parameter exists + - If it exists, verifies the includeCount parameter is not required + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-110-collections-request-includeCount-not-required' + severity: warn + given: '$.paths[*].get' + then: + function: 'IPA110CollectionsRequestIncludeCountNotRequired' + xgen-IPA-110-collections-response-define-links-array: + description: | + The response for collections should define a links array field, providing links to next and previous pages. + + ##### Implementation details + Rule checks for the following conditions: + - Only applies to List methods (GET operations that return collections of resources) + - Verifies the response schema includes a links field of type array + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-110-collections-response-define-links-array' + severity: warn + given: '$.paths[*].get.responses.200.content' + then: + field: '@key' + function: 'IPA110CollectionsResponseDefineLinksArray' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index f07c5d3dac..45b7aac7b8 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -500,6 +500,27 @@ Rule checks for the following conditions: - Verifies the pageNum query parameter is not required - Verifies the pageNum query parameter has a default value of 1 +#### xgen-IPA-110-collections-request-includeCount-not-required + + ![warn](https://img.shields.io/badge/warning-yellow) +If the request supports an includeCount query parameter, it must not be required. + +##### Implementation details +Rule checks for the following conditions: + - Only applies to List methods (GET on resource collection paths) + - Checks if includeCount query parameter exists + - If it exists, verifies the includeCount parameter is not required + +#### xgen-IPA-110-collections-response-define-links-array + + ![warn](https://img.shields.io/badge/warning-yellow) +The response for collections should define a links array field, providing links to next and previous pages. + +##### Implementation details +Rule checks for the following conditions: + - Only applies to List methods (GET operations that return collections of resources) + - Verifies the response schema includes a links field of type array + ### IPA-112 diff --git a/tools/spectral/ipa/rulesets/functions/IPA110CollectionsRequestIncludeCountNotRequired.js b/tools/spectral/ipa/rulesets/functions/IPA110CollectionsRequestIncludeCountNotRequired.js new file mode 100644 index 0000000000..457aab74ba --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA110CollectionsRequestIncludeCountNotRequired.js @@ -0,0 +1,38 @@ +import { hasException } from './utils/exceptions.js'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; +import { + getResourcePathItems, + isResourceCollectionIdentifier, + isSingletonResource, +} from './utils/resourceEvaluation.js'; + +const RULE_NAME = 'xgen-IPA-110-collections-request-includeCount-not-required'; +const ERROR_MESSAGE = 'includeCount query parameter of List method must not be required.'; + +export default (input, _, { path, documentInventory }) => { + const oas = documentInventory.resolved; + const resourcePath = path[1]; + + if ( + !isResourceCollectionIdentifier(resourcePath) || + (isResourceCollectionIdentifier(resourcePath) && isSingletonResource(getResourcePathItems(resourcePath, oas.paths))) + ) { + return; + } + + if (hasException(input, RULE_NAME)) { + collectException(input, RULE_NAME, path); + return; + } + + const includeCountParam = input?.parameters?.find((p) => p.name === 'includeCount' && p.in === 'query'); + if (!includeCountParam) { + return; + } + + if (includeCountParam.required) { + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); + } + + collectAdoption(path, RULE_NAME); +}; diff --git a/tools/spectral/ipa/rulesets/functions/IPA110CollectionsResponseDefineLinksArray.js b/tools/spectral/ipa/rulesets/functions/IPA110CollectionsResponseDefineLinksArray.js new file mode 100644 index 0000000000..2b8e427443 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA110CollectionsResponseDefineLinksArray.js @@ -0,0 +1,67 @@ +import { hasException } from './utils/exceptions.js'; +import { + collectAdoption, + collectAndReturnViolation, + collectException, + handleInternalError, +} from './utils/collectionUtils.js'; +import { + getResourcePathItems, + isResourceCollectionIdentifier, + isSingletonResource, +} from './utils/resourceEvaluation.js'; +import { resolveObject } from './utils/componentUtils.js'; + +const RULE_NAME = 'xgen-IPA-110-collections-response-define-links-array'; +const ERROR_MESSAGE = + 'The response for collections should define a links array field, providing links to next and previous pages.'; + +export default (input, _, { path, documentInventory }) => { + const oas = documentInventory.resolved; + const resourcePath = path[1]; + const mediaType = input; + + if ( + !mediaType.endsWith('json') || + !isResourceCollectionIdentifier(resourcePath) || + (isResourceCollectionIdentifier(resourcePath) && isSingletonResource(getResourcePathItems(resourcePath, oas.paths))) + ) { + return; + } + + const listMethodResponse = resolveObject(oas, path); + if (!listMethodResponse.schema) { + return; + } + + if (hasException(listMethodResponse, RULE_NAME)) { + collectException(listMethodResponse, RULE_NAME, path); + return; + } + + const errors = checkViolationsAndReturnErrors(listMethodResponse.schema, oas, path); + + if (errors.length !== 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } + + collectAdoption(path, RULE_NAME); +}; + +function checkViolationsAndReturnErrors(listResponseSchema, oas, path) { + try { + const hasLinksArray = listResponseSchema.properties?.links?.type === 'array'; + + if (!hasLinksArray) { + return [ + { + path, + message: `${ERROR_MESSAGE}`, + }, + ]; + } + return []; + } catch (e) { + handleInternalError(RULE_NAME, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js index 5e00c7fa55..45dcb1ba19 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js +++ b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js @@ -4,7 +4,7 @@ * @returns true if schema object returns results property (pagination), false otherwise */ export function schemaIsPaginated(schema) { - const hasResultsArray = schema.properties && schema.properties.results && schema.properties.results.type === 'array'; + const hasResultsArray = schema.properties?.results?.type === 'array'; return hasResultsArray; }