diff --git a/.github/workflows/spectral-lint.yml b/.github/workflows/spectral-lint.yml index e7b5b3faeb..8065fa61de 100644 --- a/.github/workflows/spectral-lint.yml +++ b/.github/workflows/spectral-lint.yml @@ -29,6 +29,13 @@ jobs: sparse-checkout: | openapi/ tools/spectral + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + - name: Install npm dependencies + run: npm install - name: Spectral action uses: stoplightio/spectral-action@2ad0b9302e32a77c1caccf474a9b2191a8060d83 with: diff --git a/package-lock.json b/package-lock.json index 28af3bf60c..192c3034ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "apache-arrow": "^19.0.1", "dotenv": "^16.4.7", "eslint-plugin-jest": "^28.10.0", + "lodash": "^4.17.21", "markdown-table": "^3.0.4", + "omit-deep-lodash": "^1.1.7", "openapi-to-postmanv2": "4.25.0", "parquet-wasm": "^0.6.1" }, @@ -9262,6 +9264,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/omit-deep-lodash": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/omit-deep-lodash/-/omit-deep-lodash-1.1.7.tgz", + "integrity": "sha512-9m9gleSMoxq3YO8aCq5pGUrqG9rKF0w/P70JHQ1ymjUQA/3+fVa2Stju9XORJKLmyLYEO3zzX40MJYaYl5Og4w==", + "license": "MIT", + "dependencies": { + "lodash": "~4.17.21" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index d3a0cabfc2..24ce9f5fe4 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "apache-arrow": "^19.0.1", "dotenv": "^16.4.7", "eslint-plugin-jest": "^28.10.0", + "lodash": "^4.17.21", "markdown-table": "^3.0.4", + "omit-deep-lodash": "^1.1.7", "openapi-to-postmanv2": "4.25.0", "parquet-wasm": "^0.6.1" }, diff --git a/tools/spectral/ipa/__tests__/createMethodRequestBodyIsGetResponse.test.js b/tools/spectral/ipa/__tests__/createMethodRequestBodyIsGetResponse.test.js new file mode 100644 index 0000000000..10927b9265 --- /dev/null +++ b/tools/spectral/ipa/__tests__/createMethodRequestBodyIsGetResponse.test.js @@ -0,0 +1,554 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const componentSchemas = { + schemas: { + SchemaOne: { + type: 'string', + }, + SchemaTwo: { + type: 'object', + properties: { + name: { + type: 'string', + readOnly: true, + }, + }, + }, + SchemaThree: { + type: 'object', + properties: { + name: { + type: 'string', + }, + someArray: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + SchemaFour: { + type: 'object', + properties: { + name: { + type: 'string', + writeOnly: true, + }, + }, + }, + SchemaCircularOne: { + type: 'object', + properties: { + thing: { + $ref: '#/components/schemas/SchemaCircularTwo', + }, + }, + }, + SchemaCircularTwo: { + type: 'object', + properties: { + otherThing: { + $ref: '#/components/schemas/SchemaCircularOne', + }, + }, + }, + }, +}; + +const animals = { + schemas: { + Animal: { + type: 'object', + oneOf: [ + { + $ref: '#/components/schemas/Dog', + }, + { + $ref: '#/components/schemas/Cat', + }, + ], + }, + Dog: { + allOf: [ + { + $ref: '#/components/schemas/Animal', + }, + ], + }, + Cat: { + allOf: [ + { + $ref: '#/components/schemas/Animal', + }, + ], + }, + }, +}; + +testRule('xgen-IPA-106-create-method-request-body-is-get-method-response', [ + { + name: 'valid methods', + document: { + components: componentSchemas, + paths: { + '/resource': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + }, + }, + }, + }, + }, + '/resourceTwo': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaTwo', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaTwo', + }, + }, + }, + }, + }, + }, + '/resourceTwo/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaFour', + }, + }, + }, + }, + }, + }, + }, + '/resourceThree': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + }, + }, + }, + }, + '/resourceThree/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}:customMethod': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid methods', + document: { + components: componentSchemas, + paths: { + '/resource': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaTwo', + }, + }, + }, + }, + }, + }, + }, + '/resourceTwo': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaTwo', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaTwo', + }, + }, + }, + }, + }, + }, + '/resourceTwo/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + }, + }, + }, + }, + }, + '/resourceThree': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + }, + }, + }, + }, + '/resourceThree/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + }, + }, + }, + }, + }, + '/resourceCircular': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaCircularOne', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaCircularOne', + }, + }, + }, + }, + }, + }, + '/resourceCircular/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaCircularTwo', + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + 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.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.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', '/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: + '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.2023-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid oneOf case', + document: { + components: animals, + paths: { + '/animalResource': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/Animal', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/Animal', + }, + }, + }, + }, + }, + }, + '/animalResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/Dog', + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + 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.2023-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid method with exception', + document: { + components: componentSchemas, + paths: { + '/resource': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-106-create-method-request-body-is-get-method-response': 'reason', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaTwo', + }, + }, + }, + }, + }, + }, + }, + '/resourceTwo': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaTwo', + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-106-create-method-request-body-is-get-method-response': 'reason', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaTwo', + }, + }, + }, + }, + }, + }, + '/resourceTwo/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + }, + }, + }, + }, + }, + '/resourceThree': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaOne', + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-106-create-method-request-body-is-get-method-response': 'reason', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + }, + }, + }, + }, + '/resourceThree/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaThree', + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/rulesets/IPA-106.yaml b/tools/spectral/ipa/rulesets/IPA-106.yaml index e9f17fdb5c..238be3dbe9 100644 --- a/tools/spectral/ipa/rulesets/IPA-106.yaml +++ b/tools/spectral/ipa/rulesets/IPA-106.yaml @@ -1,9 +1,10 @@ -# IPA-104: Create +# IPA-106: Create # http://go/ipa/106 functions: - createMethodRequestBodyIsRequestSuffixedObject - createMethodShouldNotHaveQueryParameters + - createMethodRequestBodyIsGetResponse rules: xgen-IPA-106-create-method-request-body-is-request-suffixed-object: @@ -21,3 +22,11 @@ rules: given: '$.paths[*].post' then: function: 'createMethodShouldNotHaveQueryParameters' + xgen-IPA-106-create-method-request-body-is-get-method-response: + description: 'The Create method request should be a Get method response. http://go/ipa/106' + message: '{{error}} http://go/ipa/106' + severity: warn + given: '$.paths[*].post.requestBody.content' + then: + field: '@key' + function: 'createMethodRequestBodyIsGetResponse' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 619ef6572b..0cc069658b 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -43,6 +43,7 @@ For rule definitions, see [IPA-106.yaml](https://github.com/mongodb/openapi/blob | ------------------------------------------------------------------ | -------------------------------------------------------------------------------- | -------- | | xgen-IPA-106-create-method-request-body-is-request-suffixed-object | The Create method request should be a Request suffixed object. http://go/ipa/106 | warn | | xgen-IPA-106-create-method-should-not-have-query-parameters | Create operations should not use query parameters. http://go/ipa/xxx | warn | +| xgen-IPA-106-create-method-request-body-is-get-method-response | The Create method request should be a Get method response. http://go/ipa/106 | warn | ### IPA-109 diff --git a/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsGetResponse.js b/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsGetResponse.js new file mode 100644 index 0000000000..3ade0cfdb5 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsGetResponse.js @@ -0,0 +1,67 @@ +import { getResponseOfGetMethodByMediaType, isCustomMethodIdentifier } from './utils/resourceEvaluation.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { isEqual } from 'lodash'; +import omitDeep from 'omit-deep-lodash'; +import { hasException } from './utils/exceptions.js'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; + +const RULE_NAME = 'xgen-IPA-106-create-method-request-body-is-get-method-response'; +const ERROR_MESSAGE = + 'The request body schema properties must match the response body schema properties of the Get method.'; + +export default (input, _, { path, documentInventory }) => { + const oas = documentInventory.resolved; + const resourcePath = path[1]; + let mediaType = input; + if (isCustomMethodIdentifier(resourcePath) || !mediaType.endsWith('json')) { + return; + } + + const getMethodResponseContentPerMediaType = getResponseOfGetMethodByMediaType(mediaType, resourcePath, oas); + if (!getMethodResponseContentPerMediaType || !getMethodResponseContentPerMediaType.schema) { + return; + } + + const postMethodRequestContentPerMediaType = resolveObject(oas, path); + if (!postMethodRequestContentPerMediaType.schema) { + return; + } + if (hasException(postMethodRequestContentPerMediaType, RULE_NAME)) { + collectException(postMethodRequestContentPerMediaType, RULE_NAME, path); + return; + } + + const errors = checkViolationsAndReturnErrors( + path, + postMethodRequestContentPerMediaType, + getMethodResponseContentPerMediaType + ); + + if (errors.length !== 0) { + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); + } + + collectAdoption(path, RULE_NAME); +}; + +function checkViolationsAndReturnErrors( + path, + postMethodRequestContentPerMediaType, + getMethodResponseContentPerMediaType +) { + const errors = []; + + if ( + !isEqual( + omitDeep(postMethodRequestContentPerMediaType.schema, 'readOnly', 'writeOnly'), + omitDeep(getMethodResponseContentPerMediaType.schema, 'readOnly', 'writeOnly') + ) + ) { + errors.push({ + path: path, + message: ERROR_MESSAGE, + }); + } + + return errors; +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js index e231693c65..c2abacf73e 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js +++ b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js @@ -150,3 +150,51 @@ 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; +}