diff --git a/tools/spectral/ipa/__tests__/IPA110CollectionsResponseDefineResultsArray.test.js b/tools/spectral/ipa/__tests__/IPA110CollectionsResponseDefineResultsArray.test.js new file mode 100644 index 0000000000..1077241336 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA110CollectionsResponseDefineResultsArray.test.js @@ -0,0 +1,242 @@ +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', + }, + }, + }, + }, + ResourcePaginatedList: { + type: 'object', + properties: { + totalCount: { + type: 'integer', + }, + results: { + type: 'array', + items: { + $ref: '#/components/schemas/Resource', + }, + }, + }, + }, +}; + +testRule('xgen-IPA-110-collections-response-define-results-array', [ + { + name: 'valid schemas with Paginated prefix', + 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: 'valid schemas without Paginated prefix but with correct structure', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ResourcePaginatedList', + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: componentSchemas, + }, + }, + errors: [], + }, + { + name: 'invalid schema missing results', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/PaginatedMissingResults', + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: { + PaginatedMissingResults: { + type: 'object', + properties: { + totalCount: { + type: 'integer', + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-110-collections-response-define-results-array', + message: 'The response for collections must define an array of results containing the paginated resource.', + 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', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/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/PaginatedMissingResults', + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-110-collections-response-define-results-array': 'Reason', + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: { + PaginatedMissingResults: { + type: 'object', + properties: { + totalCount: { + type: 'integer', + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-110-collections-response-define-results-array', + message: 'The response for collections must define an array of results containing the paginated resource.', + path: ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA110CollectionsUsePaginatedPrefix.test.js b/tools/spectral/ipa/__tests__/IPA110CollectionsUsePaginatedPrefix.test.js new file mode 100644 index 0000000000..81e279fb46 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA110CollectionsUsePaginatedPrefix.test.js @@ -0,0 +1,262 @@ +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', + }, + }, + }, + }, + ResourceList: { + type: 'object', + properties: { + totalCount: { + type: 'integer', + }, + results: { + type: 'array', + items: { + $ref: '#/components/schemas/Resource', + }, + }, + }, + }, +}; + +testRule('xgen-IPA-110-collections-use-paginated-prefix', [ + { + name: 'valid schemas with Paginated prefix', + 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 schemas without Paginated prefix but with correct structure', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ResourceList', + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: componentSchemas, + }, + }, + errors: [ + { + code: 'xgen-IPA-110-collections-use-paginated-prefix', + message: + 'List methods response must reference a paginated response schema. The response should reference a schema with "Paginated" prefix.', + path: ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid list methods without correct structure', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ResourceList', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: {}, + }, + 'application/vnd.atlas.2024-03-03+json': {}, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: componentSchemas, + }, + }, + errors: [ + { + code: 'xgen-IPA-110-collections-use-paginated-prefix', + message: + 'List methods response must reference a paginated response schema. The response should reference a schema with "Paginated" prefix.', + path: ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/vnd.atlas.2024-08-05+json'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-110-collections-use-paginated-prefix', + message: + 'List methods response must reference a paginated response schema. The schema is defined inline and must reference a predefined paginated schema.', + path: ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/vnd.atlas.2024-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-110-collections-use-paginated-prefix', + message: + 'List methods response must reference a paginated response schema. The List method response does not have a schema defined.', + path: ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/vnd.atlas.2024-03-03+json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: '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', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: componentSchemas, + }, + }, + errors: [ + { + code: 'xgen-IPA-110-collections-use-paginated-prefix', + message: 'The schema is defined inline and must reference a predefined paginated schema.', + path: ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid schema with exception', + document: { + paths: { + '/resources': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-110-collections-use-paginated-prefix': 'Reason', + }, + schema: { + $ref: '#/components/schemas/ResourceList', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-110-collections-use-paginated-prefix': 'Reason', + }, + schema: {}, + }, + 'application/vnd.atlas.2024-03-03+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-110-collections-use-paginated-prefix': 'Reason', + }, + }, + }, + }, + }, + }, + }, + '/resources/{id}': { + get: {}, + }, + }, + components: { + schemas: componentSchemas, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index ebcf0ee062..3bc7d2371b 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -7,6 +7,7 @@ extends: - ./rulesets/IPA-107.yaml - ./rulesets/IPA-108.yaml - ./rulesets/IPA-109.yaml + - ./rulesets/IPA-110.yaml - ./rulesets/IPA-112.yaml - ./rulesets/IPA-113.yaml - ./rulesets/IPA-117.yaml diff --git a/tools/spectral/ipa/rulesets/IPA-110.yaml b/tools/spectral/ipa/rulesets/IPA-110.yaml new file mode 100644 index 0000000000..876e64f1b8 --- /dev/null +++ b/tools/spectral/ipa/rulesets/IPA-110.yaml @@ -0,0 +1,37 @@ +# IPA-110: Pagination +# http://go/ipa/110 + +functions: + - IPA110CollectionsUsePaginatedPrefix + - IPA110CollectionsResponseDefineResultsArray + +rules: + xgen-IPA-110-collections-use-paginated-prefix: + description: | + APIs that return collections of resources must use a schema with the "Paginated" prefix. + + ##### Implementation details + Rule checks for the following conditions: + - Only applies to List methods (GET operations that return collections of resources) + - Checks if List method has a response schema defined + - Checks that the 200 response schema references a schema with a "Paginated" prefix + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-110-collections-use-paginated-prefix' + severity: warn + given: '$.paths[*].get.responses.200.content' + then: + field: '@key' + function: 'IPA110CollectionsUsePaginatedPrefix' + xgen-IPA-110-collections-response-define-results-array: + description: | + The response for collections must define an array of results containing the paginated resource. + + ##### Implementation details + Rule checks for the following conditions: + - Only applies to List methods (GET operations that return collections of resources) + - Verifies the 200 response schema has the required results fields + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-110-collections-response-define-results-array' + severity: warn + given: '$.paths[*].get.responses.200.content' + then: + field: '@key' + function: 'IPA110CollectionsResponseDefineResultsArray' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index da9199092e..3a60497e77 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -447,6 +447,33 @@ Rule checks for the following conditions: +### IPA-110 + +Rules are based on [http://go/ipa/IPA-110](http://go/ipa/IPA-110). + +#### xgen-IPA-110-collections-use-paginated-prefix + + ![warn](https://img.shields.io/badge/warning-yellow) +APIs that return collections of resources must use a schema with the "Paginated" prefix. + +##### Implementation details +Rule checks for the following conditions: + - Only applies to List methods (GET operations that return collections of resources) + - Checks if List method has a response schema defined + - Checks that the 200 response schema references a schema with a "Paginated" prefix + +#### xgen-IPA-110-collections-response-define-results-array + + ![warn](https://img.shields.io/badge/warning-yellow) +The response for collections must define an array of results containing the paginated resource. + +##### Implementation details +Rule checks for the following conditions: + - Only applies to List methods (GET operations that return collections of resources) + - Verifies the 200 response schema has the required results fields + + + ### IPA-112 Rules are based on [http://go/ipa/IPA-112](http://go/ipa/IPA-112). diff --git a/tools/spectral/ipa/rulesets/functions/IPA110CollectionsResponseDefineResultsArray.js b/tools/spectral/ipa/rulesets/functions/IPA110CollectionsResponseDefineResultsArray.js new file mode 100644 index 0000000000..11b115535b --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA110CollectionsResponseDefineResultsArray.js @@ -0,0 +1,65 @@ +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'; +import { schemaIsPaginated } from './utils/schemaUtils.js'; + +const RULE_NAME = 'xgen-IPA-110-collections-use-paginated-schema'; +const ERROR_MESSAGE = 'The response for collections must define an array of results containing the paginated resource.'; + +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 { + if (!schemaIsPaginated(listResponseSchema)) { + return [ + { + path, + message: `${ERROR_MESSAGE}`, + }, + ]; + } + return []; + } catch (e) { + handleInternalError(RULE_NAME, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA110CollectionsUsePaginatedPrefix.js b/tools/spectral/ipa/rulesets/functions/IPA110CollectionsUsePaginatedPrefix.js new file mode 100644 index 0000000000..4591bdac2b --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA110CollectionsUsePaginatedPrefix.js @@ -0,0 +1,84 @@ +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'; +import { getSchemaNameFromRef } from './utils/methodUtils.js'; + +const RULE_NAME = 'xgen-IPA-110-collections-use-paginated-prefix'; +const ERROR_MESSAGE = 'List methods response must reference a paginated response schema.'; + +export default (input, _, { path, documentInventory }) => { + const oas = documentInventory.unresolved; + 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 (hasException(listMethodResponse, RULE_NAME)) { + collectException(listMethodResponse, RULE_NAME, path); + return; + } + + const errors = checkViolationsAndReturnErrors(listMethodResponse, oas, path); + + if (errors.length !== 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } + + collectAdoption(path, RULE_NAME); +}; + +function checkViolationsAndReturnErrors(listMethodResponse, oas, path) { + try { + if (!listMethodResponse.schema) { + return [ + { + path, + message: `${ERROR_MESSAGE} The List method response does not have a schema defined.`, + }, + ]; + } + + if (!listMethodResponse.schema.$ref) { + return [ + { + path, + message: `${ERROR_MESSAGE} The schema is defined inline and must reference a predefined paginated schema.`, + }, + ]; + } + + const schemaRef = listMethodResponse.schema.$ref; + const schemaName = getSchemaNameFromRef(schemaRef); + + if (!schemaName.startsWith('Paginated')) { + return [ + { + path, + message: `${ERROR_MESSAGE} The response should reference a schema with "Paginated" prefix.`, + }, + ]; + } + + 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 2c15b69e28..5e00c7fa55 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js +++ b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js @@ -4,12 +4,9 @@ * @returns true if schema object returns results property (pagination), false otherwise */ export function schemaIsPaginated(schema) { - const fields = Object.keys(schema); - return ( - fields.includes('properties') && - Object.keys(schema['properties']).includes('results') && - schema.properties.results.type === 'array' - ); + const hasResultsArray = schema.properties && schema.properties.results && schema.properties.results.type === 'array'; + + return hasResultsArray; } /**