diff --git a/tools/spectral/ipa/__tests__/createMethodRequestBodyIsGetResponse.test.js b/tools/spectral/ipa/__tests__/createMethodRequestBodyIsGetResponse.test.js index b320736887..520493d295 100644 --- a/tools/spectral/ipa/__tests__/createMethodRequestBodyIsGetResponse.test.js +++ b/tools/spectral/ipa/__tests__/createMethodRequestBodyIsGetResponse.test.js @@ -318,6 +318,31 @@ testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ }, }, }, + // Missing schema for Get method + '/resourceFour': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + }, + }, + }, + }, + '/resourceFour/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': {}, + }, + }, + }, + }, + }, '/resourceCircular': { post: { requestBody: { @@ -361,6 +386,13 @@ testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ path: ['paths', '/resource', 'post', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], severity: DiagnosticSeverity.Warning, }, + { + code: 'xgen-IPA-106-create-method-request-body-is-get-method-response', + message: + 'The request body schema properties must match the response body schema properties of the Get method. http://go/ipa/106', + path: ['paths', '/resource', 'post', 'requestBody', 'content', 'application/vnd.atlas.2024-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, { code: 'xgen-IPA-106-create-method-request-body-is-get-method-response', message: @@ -368,6 +400,13 @@ testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ path: ['paths', '/resourceTwo', 'post', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], severity: DiagnosticSeverity.Warning, }, + { + code: 'xgen-IPA-106-create-method-request-body-is-get-method-response', + message: + 'The request body schema properties must match the response body schema properties of the Get method. http://go/ipa/106', + path: ['paths', '/resourceTwo', 'post', 'requestBody', 'content', 'application/vnd.atlas.2024-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, { code: 'xgen-IPA-106-create-method-request-body-is-get-method-response', message: @@ -375,6 +414,13 @@ testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ path: ['paths', '/resourceThree', 'post', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], severity: DiagnosticSeverity.Warning, }, + { + code: 'xgen-IPA-106-create-method-request-body-is-get-method-response', + message: + 'Could not validate that the Create request body schema matches the response schema of the Get method. The Get method does not have a schema. http://go/ipa/106', + path: ['paths', '/resourceFour', 'post', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, { code: 'xgen-IPA-106-create-method-request-body-is-get-method-response', message: @@ -382,6 +428,13 @@ testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ path: ['paths', '/resourceCircular', 'post', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], severity: DiagnosticSeverity.Warning, }, + { + code: 'xgen-IPA-106-create-method-request-body-is-get-method-response', + message: + 'The request body schema properties must match the response body schema properties of the Get method. http://go/ipa/106', + path: ['paths', '/resourceCircular', 'post', 'requestBody', 'content', 'application/vnd.atlas.2024-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, ], }, { @@ -432,6 +485,13 @@ testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ path: ['paths', '/animalResource', 'post', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], severity: DiagnosticSeverity.Warning, }, + { + code: 'xgen-IPA-106-create-method-request-body-is-get-method-response', + message: + 'The request body schema properties must match the response body schema properties of the Get method. http://go/ipa/106', + path: ['paths', '/animalResource', 'post', 'requestBody', 'content', 'application/vnd.atlas.2024-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, ], }, { @@ -455,6 +515,9 @@ testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ schema: { $ref: '#/components/schemas/SchemaOne', }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-106-create-method-request-body-is-get-method-response': 'reason', + }, }, }, }, @@ -491,6 +554,9 @@ testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ schema: { $ref: '#/components/schemas/SchemaTwo', }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-106-create-method-request-body-is-get-method-response': 'reason', + }, }, }, }, diff --git a/tools/spectral/ipa/__tests__/listMethodResponseIsGetMethodResponse.test.js b/tools/spectral/ipa/__tests__/listMethodResponseIsGetMethodResponse.test.js new file mode 100644 index 0000000000..c1e94ef5f0 --- /dev/null +++ b/tools/spectral/ipa/__tests__/listMethodResponseIsGetMethodResponse.test.js @@ -0,0 +1,577 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const componentSchemas = { + schemas: { + PaginatedResourceSchema: { + properties: { + results: { + type: 'array', + items: { + $ref: '#/components/schemas/ResourceSchema', + }, + }, + }, + }, + ResourceSchema: { + type: 'object', + }, + PaginatedArraySchema: { + properties: { + results: { + type: 'array', + items: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + ArraySchema: { + type: 'array', + items: { + type: 'string', + }, + }, + }, +}; + +testRule('xgen-IPA-105-list-method-response-is-get-method-response', [ + { + name: 'valid list', + document: { + paths: { + // Using ref + '/resource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/PaginatedResourceSchema', + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ResourceSchema', + }, + }, + }, + }, + }, + }, + }, + // Inline schema + '/arrayResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + properties: { + results: { + type: 'array', + items: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/arrayResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + // Multiple versions + '/versionedResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/PaginatedResourceSchema', + }, + }, + 'application/vnd.atlas.2024-01-05+json': { + schema: { + $ref: '#/components/schemas/PaginatedArraySchema', + }, + }, + }, + }, + }, + }, + }, + '/versionedResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ResourceSchema', + }, + }, + 'application/vnd.atlas.2024-01-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: componentSchemas, + }, + errors: [], + }, + { + name: 'rule ignores inapplicable cases', + document: { + paths: { + // No Get method + '/resource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + // Singleton + '/resource/{id}/singleton': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + // Not paginated (covered by separate rule, IPA 110) + '/resource/{id}/unPaginated': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}/unPaginated/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ResourceSchema', + }, + }, + }, + }, + }, + }, + }, + // Version mismatch + '/versionedResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-01-05+json': { + schema: { + $ref: '#/components/schemas/PaginatedResourceSchema', + }, + }, + }, + }, + }, + }, + }, + '/versionedResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: componentSchemas, + }, + errors: [], + }, + { + name: 'invalid list', + document: { + paths: { + // Using ref + '/resource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/PaginatedArraySchema', + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ResourceSchema', + }, + }, + }, + }, + }, + }, + }, + // Inline schema + '/arrayResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + properties: { + results: { + type: 'array', + items: { + schema: { + $ref: '#/components/schemas/ResourceSchema', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/arrayResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + // Get without schema + '/resourceTwo': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-01-05+json': { + schema: { + $ref: '#/components/schemas/PaginatedResourceSchema', + }, + }, + }, + }, + }, + }, + }, + '/resourceTwo/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-01-05+json': {}, + }, + }, + }, + }, + }, + // Get without schema ref + '/resourceThree': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-01-05+json': { + schema: { + $ref: '#/components/schemas/PaginatedResourceSchema', + }, + }, + }, + }, + }, + }, + }, + '/resourceThree/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-01-05+json': { + schema: { + type: 'object', + }, + }, + }, + }, + }, + }, + }, + }, + components: componentSchemas, + }, + errors: [ + { + code: 'xgen-IPA-105-list-method-response-is-get-method-response', + message: + 'The schema of each result in the List method response must be the same schema as the response of the Get method. http://go/ipa-spectral#IPA-105', + path: ['paths', '/resource', 'get', 'responses', '200', 'content', 'application/vnd.atlas.2024-08-05+json'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-105-list-method-response-is-get-method-response', + message: + 'The schema of each result in the List method response must be the same schema as the response of the Get method. http://go/ipa-spectral#IPA-105', + path: [ + 'paths', + '/arrayResource', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-105-list-method-response-is-get-method-response', + message: + 'Could not validate that the List method returns the same resource object as the Get method. The Get method does not have a schema. http://go/ipa-spectral#IPA-105', + path: ['paths', '/resourceTwo', 'get', 'responses', '200', 'content', 'application/vnd.atlas.2024-01-05+json'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-105-list-method-response-is-get-method-response', + message: + 'Could not validate that the List method returns the same resource object as the Get method. The Get method does not have a schema reference. http://go/ipa-spectral#IPA-105', + path: [ + 'paths', + '/resourceThree', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-01-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid list with version mismatch', + document: { + paths: { + '/resource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/PaginatedResourceSchema', + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-01-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: componentSchemas, + }, + errors: [ + { + code: 'xgen-IPA-105-list-method-response-is-get-method-response', + message: + 'The schema of each result in the List method response must be the same schema as the response of the Get method. http://go/ipa-spectral#IPA-105', + path: ['paths', '/resource', 'get', 'responses', '200', 'content', 'application/vnd.atlas.2024-08-05+json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid with exception', + document: { + paths: { + // Using ref + '/resource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-105-list-method-response-is-get-method-response': 'reason', + }, + schema: { + $ref: '#/components/schemas/PaginatedArraySchema', + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ResourceSchema', + }, + }, + }, + }, + }, + }, + }, + // Inline schema + '/arrayResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-105-list-method-response-is-get-method-response': 'reason', + }, + schema: { + properties: { + results: { + type: 'array', + items: { + schema: { + $ref: '#/components/schemas/ResourceSchema', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/arrayResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/ArraySchema', + }, + }, + }, + }, + }, + }, + }, + }, + components: componentSchemas, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/utils/methodUtils.test.js b/tools/spectral/ipa/__tests__/utils/methodUtils.test.js new file mode 100644 index 0000000000..13da0b1577 --- /dev/null +++ b/tools/spectral/ipa/__tests__/utils/methodUtils.test.js @@ -0,0 +1,185 @@ +import { describe, expect, it } from '@jest/globals'; +import { + getAllSuccessfulResponseSchemas, + getResponseOfGetMethodByMediaType, + getResponseOfListMethodByMediaType, +} from '../../rulesets/functions/utils/methodUtils.js'; + +describe('tools/spectral/ipa/rulesets/functions/utils/methodUtils.js', () => { + describe('getAllSuccessfulResponseSchemas', () => { + const operationObject = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'array', + }, + }, + }, + description: 'OK', + }, + 401: { + content: { + 'application/json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + }; + + it('returns all 2xx response schemas', () => { + const result = getAllSuccessfulResponseSchemas(operationObject); + expect(result).toHaveLength(2); + + expect(result[0].schemaPath).toEqual(['responses', '200', 'content', 'application/vnd.atlas.2023-01-01+json']); + expect(result[0].schema.type).toEqual('object'); + + expect(result[1].schemaPath).toEqual(['responses', '200', 'content', 'application/vnd.atlas.2024-01-01+json']); + expect(result[1].schema.type).toEqual('array'); + }); + }); + + describe('getResponseOfGetMethodByMediaType', () => { + const oas = { + paths: { + '/resource': {}, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'array', + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const testCases = [ + { + description: 'Exact version', + requestedVersion: 'application/vnd.atlas.2023-01-01+json', + expectedMatch: 'application/vnd.atlas.2023-01-01+json', + }, + { + description: 'Exact version', + requestedVersion: 'application/vnd.atlas.2024-01-01+json', + expectedMatch: 'application/vnd.atlas.2024-01-01+json', + }, + { + description: 'Latest past version', + requestedVersion: 'application/vnd.atlas.2024-10-01+json', + expectedMatch: 'application/vnd.atlas.2024-01-01+json', + }, + { + description: 'Latest past version', + requestedVersion: 'application/vnd.atlas.2023-10-01+json', + expectedMatch: 'application/vnd.atlas.2023-01-01+json', + }, + { + description: 'No match', + requestedVersion: 'application/vnd.atlas.2020-10-01+json', + expectedMatch: '', + }, + ]; + + testCases.forEach((testCase) => { + it(`returns the expected match for ${testCase.description}`, () => { + const result = getResponseOfGetMethodByMediaType(testCase.requestedVersion, '/resource', oas); + + if (!testCase.expectedMatch) { + expect(result).toBeNull(); + } else { + expect(result).toEqual(oas.paths['/resource/{id}'].get.responses['200'].content[testCase.expectedMatch]); + } + }); + }); + }); + + describe('getResponseOfListMethodByMediaType', () => { + const oas = { + paths: { + '/resource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'array', + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const testCases = [ + { + description: 'Exact version', + requestedVersion: 'application/vnd.atlas.2023-01-01+json', + expectedMatch: 'application/vnd.atlas.2023-01-01+json', + }, + { + description: 'Exact version', + requestedVersion: 'application/vnd.atlas.2024-01-01+json', + expectedMatch: 'application/vnd.atlas.2024-01-01+json', + }, + { + description: 'Latest past version', + requestedVersion: 'application/vnd.atlas.2024-10-01+json', + expectedMatch: 'application/vnd.atlas.2024-01-01+json', + }, + { + description: 'Latest past version', + requestedVersion: 'application/vnd.atlas.2023-10-01+json', + expectedMatch: 'application/vnd.atlas.2023-01-01+json', + }, + { + description: 'No match', + requestedVersion: 'application/vnd.atlas.2020-10-01+json', + expectedMatch: '', + }, + ]; + + testCases.forEach((testCase) => { + it(`returns the expected match for ${testCase.description}`, () => { + const result = getResponseOfListMethodByMediaType(testCase.requestedVersion, '/resource', oas); + + if (!testCase.expectedMatch) { + expect(result).toBeNull(); + } else { + expect(result).toEqual(oas.paths['/resource'].get.responses['200'].content[testCase.expectedMatch]); + } + }); + }); + }); +}); diff --git a/tools/spectral/ipa/rulesets/IPA-105.yaml b/tools/spectral/ipa/rulesets/IPA-105.yaml index aed20f79ab..8e46f25bcb 100644 --- a/tools/spectral/ipa/rulesets/IPA-105.yaml +++ b/tools/spectral/ipa/rulesets/IPA-105.yaml @@ -5,6 +5,7 @@ functions: - listResponseCodeShouldBe200OK - listMethodHasNoRequestBody - eachResourceHasListMethod + - listMethodResponseIsGetMethodResponse rules: xgen-IPA-105-list-method-response-code-is-200: @@ -29,3 +30,23 @@ rules: then: field: '@key' function: 'eachResourceHasListMethod' + xgen-IPA-105-list-method-response-is-get-method-response: + description: >- + The response body of the List method should consist of the same resource object returned by the Get method. http://go/ipa/105 + + ##### Implementation details + + Validation checks that the List method response contains items property with reference to the same schema as the Get method response. + + - Validation applies to List methods for resource collections only + - Validation applies to json response content only + - Validation ignores responses without schema and non-paginated responses + - A response is considered paginated if it contains an array property named `results` + - Validation ignores resources without a Get method + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} http://go/ipa-spectral#IPA-105' + severity: warn + given: '$.paths[*].get.responses[*].content' + then: + field: '@key' + function: 'listMethodResponseIsGetMethodResponse' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index ec0f038b34..6836d7a540 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -107,6 +107,20 @@ The List method request must not include a body. http://go/ipa/105 ![warn](https://img.shields.io/badge/warning-yellow) APIs must provide a List method for resources. http://go/ipa/105 +#### xgen-IPA-105-list-method-response-is-get-method-response + + ![warn](https://img.shields.io/badge/warning-yellow) +The response body of the List method should consist of the same resource object returned by the Get method. http://go/ipa/105 +##### Implementation details +Validation checks that the List method response contains items property with reference to the same schema as the Get method response. + + - Validation applies to List methods for resource collections only + - Validation applies to json response content only + - Validation ignores responses without schema and non-paginated responses + - A response is considered paginated if it contains an array property named `results` + - Validation ignores resources without a Get method + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + ### IPA-106 diff --git a/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsGetResponse.js b/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsGetResponse.js index cce6e71c7c..8feda5ff13 100644 --- a/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsGetResponse.js +++ b/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsGetResponse.js @@ -1,6 +1,5 @@ import { getResourcePathItems, - getResponseOfGetMethodByMediaType, isCustomMethodIdentifier, isResourceCollectionIdentifier, isSingletonResource, @@ -9,6 +8,7 @@ import { resolveObject } from './utils/componentUtils.js'; import { isDeepEqual, omitDeep } from './utils/compareUtils.js'; import { hasException } from './utils/exceptions.js'; import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; +import { getResponseOfGetMethodByMediaType } from './utils/methodUtils.js'; const RULE_NAME = 'xgen-IPA-106-create-method-request-body-is-get-method-response'; const ERROR_MESSAGE = @@ -26,7 +26,7 @@ export default (input, opts, { path, documentInventory }) => { } const getMethodResponseContentPerMediaType = getResponseOfGetMethodByMediaType(mediaType, resourcePath, oas); - if (!getMethodResponseContentPerMediaType || !getMethodResponseContentPerMediaType.schema) { + if (!getMethodResponseContentPerMediaType) { return; } @@ -47,7 +47,7 @@ export default (input, opts, { path, documentInventory }) => { ); if (errors.length !== 0) { - return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); + return collectAndReturnViolation(path, RULE_NAME, errors); } collectAdoption(path, RULE_NAME); @@ -62,6 +62,14 @@ function checkViolationsAndReturnErrors( const errors = []; const ignoredValues = opts?.ignoredValues || []; + if (!getMethodResponseContentPerMediaType.schema) { + return [ + { + path, + message: `Could not validate that the Create request body schema matches the response schema of the Get method. The Get method does not have a schema.`, + }, + ]; + } if ( !isDeepEqual( omitDeep(postMethodRequestContentPerMediaType.schema, ...ignoredValues), @@ -69,7 +77,7 @@ function checkViolationsAndReturnErrors( ) ) { errors.push({ - path: path, + path, message: ERROR_MESSAGE, }); } diff --git a/tools/spectral/ipa/rulesets/functions/listMethodResponseIsGetMethodResponse.js b/tools/spectral/ipa/rulesets/functions/listMethodResponseIsGetMethodResponse.js new file mode 100644 index 0000000000..7f5554918c --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/listMethodResponseIsGetMethodResponse.js @@ -0,0 +1,115 @@ +import { + getResourcePathItems, + isResourceCollectionIdentifier, + isSingletonResource, +} from './utils/resourceEvaluation.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { hasException } from './utils/exceptions.js'; +import { + collectAdoption, + collectAndReturnViolation, + collectException, + handleInternalError, +} from './utils/collectionUtils.js'; +import { getSchemaRef, getSchemaNameFromRef, getResponseOfGetMethodByMediaType } from './utils/methodUtils.js'; +import { schemaIsPaginated } from './utils/schemaUtils.js'; + +const RULE_NAME = 'xgen-IPA-105-list-method-response-is-get-method-response'; +const ERROR_MESSAGE = + 'The schema of each result in the List method response must be the same schema as the response of the Get method.'; + +export default (input, _, { path, documentInventory }) => { + const oas = documentInventory.unresolved; + const resourcePath = path[1]; + const responseCode = path[4]; + const mediaType = input; + + if ( + responseCode !== '200' || + !mediaType.endsWith('json') || + !isResourceCollectionIdentifier(resourcePath) || + (isResourceCollectionIdentifier(resourcePath) && isSingletonResource(getResourcePathItems(resourcePath, oas.paths))) + ) { + return; + } + + // Ignore if the List method does not have a response schema + const listMethodResponse = resolveObject(oas, path); + + if (!listMethodResponse || !listMethodResponse.schema) { + return; + } + + if (hasException(listMethodResponse, RULE_NAME)) { + collectException(listMethodResponse, RULE_NAME, path); + return; + } + + // Get list response schema from ref or inline schema + let resolvedListSchema; + const listSchemaRef = getSchemaRef(listMethodResponse.schema); + if (!listSchemaRef) { + resolvedListSchema = listMethodResponse.schema; + } else { + const listSchemaName = getSchemaNameFromRef(listSchemaRef); + resolvedListSchema = resolveObject(oas, ['components', 'schemas', listSchemaName]); + } + + // Ignore if the List method response is not found or not paginated + if (!resolvedListSchema || !schemaIsPaginated(resolvedListSchema)) { + return; + } + + // Ignore if there is no matching Get method + const getMethodResponseContentPerMediaType = getResponseOfGetMethodByMediaType(mediaType, resourcePath, oas); + if (!getMethodResponseContentPerMediaType) { + return; + } + + const errors = checkViolationsAndReturnErrors( + path, + resolvedListSchema.properties.results.items, + getMethodResponseContentPerMediaType + ); + + if (errors.length !== 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } + + collectAdoption(path, RULE_NAME); +}; + +function checkViolationsAndReturnErrors(path, listMethodResultItems, getMethodResponseContent) { + try { + // Error if the Get method does not have a schema + if (!getMethodResponseContent.schema) { + return [ + { + path, + message: `Could not validate that the List method returns the same resource object as the Get method. The Get method does not have a schema.`, + }, + ]; + } + + const listMethodSchemaRef = getSchemaRef(listMethodResultItems); + const getMethodSchemaRef = getSchemaRef(getMethodResponseContent.schema); + + // Error if the Get method does not have a schema ref + if (!getMethodSchemaRef) { + return [ + { + path, + message: `Could not validate that the List method returns the same resource object as the Get method. The Get method does not have a schema reference.`, + }, + ]; + } + + // Error if the get method resource is not the same as the list method resource + if (getMethodSchemaRef !== listMethodSchemaRef) { + return [{ path, message: ERROR_MESSAGE }]; + } + return []; + } catch (e) { + handleInternalError(RULE_NAME, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js b/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js index 0f97df3f52..2acb191e51 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js +++ b/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js @@ -1,3 +1,6 @@ +import { getResourcePathItems, hasGetMethod, isSingleResourceIdentifier } from './resourceEvaluation.js'; +import { schemaIsArray } from './schemaUtils.js'; + /** * Returns a list of all successful response schemas for the passed operation, i.e. for any 2xx response. * @@ -37,8 +40,116 @@ export function getAllSuccessfulResponseSchemas(operationObject) { * @returns {string} the schema ref */ export function getSchemaRef(schema) { - if (schema.type === 'array' && schema.items) { + if (schemaIsArray(schema) && schema.items) { return schema.items.$ref; } return schema.$ref; } + +/** + * Gets the schema name from a schema reference, for example '#/components/schemas/ResourceSchema' + * returns 'ResourceSchema'. + * + * @param {string} schemaRef the schema reference + * @returns {string} the schema name + */ +export function getSchemaNameFromRef(schemaRef) { + const sections = schemaRef.split('/'); + return sections[sections.length - 1]; +} + +/** + * Retrieves the response schema of the Get method for a resource by media type. + * If the OAS is unresolved, the returning schema may contain a reference to a schema definition. + * If there is no response with the exact media type version, the latest version before the passed one is returned, otherwise null + * + * This function: + * 1. Finds all path items related to the resource collection + * 2. Identifies the single resource path (e.g., '/resource/{id}') + * 3. Checks if the single resource has a GET method + * 4. Returns the response schema for the specified media type if available, or the latest version + * + * @param {string} mediaType - The media type to retrieve the schema for (e.g., 'application/vnd.atlas.2023-01-01+json') + * @param {string} pathForResourceCollection - The path for the collection of resources (e.g., '/resource') + * @param {Object} oas - The resolved or unresolved OpenAPI document + * @returns {Object|null} - The response schema for the specified media type, or null if not found + */ +export function getResponseOfGetMethodByMediaType(mediaType, pathForResourceCollection, oas) { + const resourcePathItems = getResourcePathItems(pathForResourceCollection, oas.paths); + const resourcePaths = Object.keys(resourcePathItems); + if (resourcePaths.length === 1) { + return null; + } + + const singleResourcePath = resourcePaths.find((path) => isSingleResourceIdentifier(path)); + if (!singleResourcePath) { + return null; + } + + const singleResourcePathObject = resourcePathItems[singleResourcePath]; + + return getGETMethodResponseSchemaFromPathItem(singleResourcePathObject, mediaType); +} + +/** + * Retrieves the 200 response schema of the List method for a resource by media type. + * If the OAS is unresolved, the returning schema may contain a reference to a schema definition. + * If there is no response with the exact media type version, the latest version before the passed one is returned, otherwise null. + * + * @param {string} mediaType - The media type to retrieve the schema for (e.g., 'application/vnd.atlas.2023-01-01+json') + * @param {string} pathForResourceCollection - The path for the collection of resources (e.g., '/resource') + * @param {Object} oas - The resolved or unresolved OpenAPI document + * @returns {Object|null} - The response schema for the specified media type, or null if not found + */ +export function getResponseOfListMethodByMediaType(mediaType, pathForResourceCollection, oas) { + const pathObject = oas.paths[pathForResourceCollection]; + if (!pathObject) { + return null; + } + + return getGETMethodResponseSchemaFromPathItem(pathObject, mediaType); +} + +/** + * Returns the schema for the 200 response of the GET method for a path item by media type. + * If there is no response with the exact media type version, the latest version before the passed one is returned, otherwise null. + * + * @param {Object} pathItem The path item to extract the GET response from + * @param {string} mediaType The media type + * @returns {Object|null} The schema object, or null if not found + */ +function getGETMethodResponseSchemaFromPathItem(pathItem, mediaType) { + if (!hasGetMethod(pathItem)) { + return null; + } + + const getMethodObject = pathItem.get; + if (!getMethodObject.responses) { + return null; + } + + const response = getMethodObject.responses['200']; + if (!response || !response.content) { + return null; + } + + const versions = Object.keys(response.content); + if (versions.includes(mediaType)) { + const schema = response.content[mediaType]; + if (!schema) { + return null; + } + return schema; + } + + const orderedVersions = versions.sort().reverse(); + const latestVersionMatch = orderedVersions.find((version) => version < mediaType); + if (latestVersionMatch) { + const schema = response.content[latestVersionMatch]; + if (!schema) { + return null; + } + return schema; + } + return null; +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js index b8eb785f29..98c38d3d8d 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js +++ b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js @@ -168,51 +168,3 @@ function removePrefix(path) { } return path; } - -/** - * Retrieves the response schema of the GET method for a resource by media type - * - * This function: - * 1. Finds all path items related to the resource collection - * 2. Identifies the single resource path (e.g., '/resource/{id}') - * 3. Checks if the single resource has a GET method - * 4. Returns the response schema for the specified media type if available - * - * @param {string} mediaType - The media type to retrieve the schema for (e.g., 'application/vnd.atlas.2023-01-01+json') - * @param {string} pathForResourceCollection - The path for the collection of resources (e.g., '/resource') - * @param {Object} oas - The resolved OpenAPI document - * @returns {Object|null} - The response schema for the specified media type, or null if not found - */ -export function getResponseOfGetMethodByMediaType(mediaType, pathForResourceCollection, oas) { - const resourcePathItems = getResourcePathItems(pathForResourceCollection, oas.paths); - const resourcePaths = Object.keys(resourcePathItems); - if (resourcePaths.length === 1) { - return null; - } - - const singleResourcePath = resourcePaths.find((path) => isSingleResourceIdentifier(path)); - if (!singleResourcePath) { - return null; - } - - const singleResourcePathObject = resourcePathItems[singleResourcePath]; - if (!hasGetMethod(singleResourcePathObject)) { - return null; - } - - const getMethodObject = singleResourcePathObject.get; - if (!getMethodObject.responses) { - return null; - } - - const response = getMethodObject.responses['200']; - if (!response) { - return null; - } - - const schema = response.content[mediaType]; - if (!schema) { - return null; - } - return schema; -} diff --git a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js index 11648b38f1..0bec1d4a4e 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js +++ b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js @@ -5,7 +5,11 @@ */ export function schemaIsPaginated(schema) { const fields = Object.keys(schema); - return fields.includes('properties') && Object.keys(schema['properties']).includes('results'); + return ( + fields.includes('properties') && + Object.keys(schema['properties']).includes('results') && + schema.properties.results.type === 'array' + ); } /**