diff --git a/tools/spectral/ipa/__tests__/IPA107UpdateMethodRequestHasNoReadonlyFields.test.js b/tools/spectral/ipa/__tests__/IPA107UpdateMethodRequestHasNoReadonlyFields.test.js new file mode 100644 index 0000000000..9dd3838725 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA107UpdateMethodRequestHasNoReadonlyFields.test.js @@ -0,0 +1,302 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const componentSchemas = { + schemas: { + SchemaWithReadOnly: { + type: 'object', + properties: { + id: { + type: 'string', + readOnly: true, + }, + name: { + type: 'string', + }, + }, + }, + SchemaWithoutReadOnly: { + type: 'object', + properties: { + name: { + type: 'string', + }, + description: { + type: 'string', + }, + }, + }, + NestedSchemaWithReadOnly: { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + userId: { + type: 'string', + readOnly: true, + }, + username: { + type: 'string', + }, + }, + }, + }, + }, + ArraySchemaWithReadOnly: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + itemId: { + type: 'string', + readOnly: true, + }, + itemName: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, +}; + +testRule('xgen-IPA-107-update-method-request-has-no-readonly-fields', [ + { + name: 'valid methods - no readOnly fields', + document: { + components: componentSchemas, + paths: { + '/resource/{id}': { + patch: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaWithoutReadOnly', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + put: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaWithoutReadOnly', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid methods - direct readOnly field', + document: { + components: componentSchemas, + paths: { + '/resource/{id}': { + patch: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaWithReadOnly', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'string', + readOnly: true, + }, + }, + }, + }, + }, + put: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaWithReadOnly', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'string', + readOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-107-update-method-request-has-no-readonly-fields', + message: + 'The Update method request object must not include input fields (readOnly properties). Found readOnly property at: id. ', + path: ['paths', '/resource/{id}', 'patch', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-107-update-method-request-has-no-readonly-fields', + message: + 'The Update method request object must not include input fields (readOnly properties). Found readOnly property at one of the inline schemas. ', + path: ['paths', '/resource/{id}', 'patch', 'requestBody', 'content', 'application/vnd.atlas.2024-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-107-update-method-request-has-no-readonly-fields', + message: + 'The Update method request object must not include input fields (readOnly properties). Found readOnly property at: id. ', + path: ['paths', '/resource/{id}', 'put', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-107-update-method-request-has-no-readonly-fields', + message: + 'The Update method request object must not include input fields (readOnly properties). Found readOnly property at one of the inline schemas. ', + path: ['paths', '/resource/{id}', 'put', 'requestBody', 'content', 'application/vnd.atlas.2024-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid methods - nested readOnly field', + document: { + components: componentSchemas, + paths: { + '/resource/{id}': { + patch: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/NestedSchemaWithReadOnly', + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-107-update-method-request-has-no-readonly-fields', + message: + 'The Update method request object must not include input fields (readOnly properties). Found readOnly property at: user.userId. ', + path: ['paths', '/resource/{id}', 'patch', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid methods - array with readOnly field', + document: { + components: componentSchemas, + paths: { + '/resource/{id}': { + patch: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/ArraySchemaWithReadOnly', + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-107-update-method-request-has-no-readonly-fields', + message: + 'The Update method request object must not include input fields (readOnly properties). Found readOnly property at: items.items.itemId. ', + path: ['paths', '/resource/{id}', 'patch', 'requestBody', 'content', 'application/vnd.atlas.2023-01-01+json'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'methods with exceptions', + document: { + components: componentSchemas, + paths: { + '/resource': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaWithReadOnly', + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-107-update-method-request-has-no-readonly-fields': 'Reason', + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + patch: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaWithReadOnly', + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-107-update-method-request-has-no-readonly-fields': 'Reason', + }, + }, + }, + }, + }, + put: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaWithReadOnly', + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-107-update-method-request-has-no-readonly-fields': 'Reason', + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/utils/schemaUtils.test.js b/tools/spectral/ipa/__tests__/utils/schemaUtils.test.js deleted file mode 100644 index d46714e27b..0000000000 --- a/tools/spectral/ipa/__tests__/utils/schemaUtils.test.js +++ /dev/null @@ -1,247 +0,0 @@ -// schemaUtils.test.js -import { findPropertiesByAttribute } from '../../rulesets/functions/utils/schemaUtils'; -import { describe, expect, it } from '@jest/globals'; - -describe('findPropertiesByAttribute', () => { - const mockPath = ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/json']; - const errorMessage = 'Test error message'; - - it('handles primitive values', () => { - expect(findPropertiesByAttribute(null, 'readOnly', mockPath, [], errorMessage)).toEqual([]); - expect(findPropertiesByAttribute(undefined, 'readOnly', mockPath, [], errorMessage)).toEqual([]); - expect(findPropertiesByAttribute('string', 'readOnly', mockPath, [], errorMessage)).toEqual([]); - expect(findPropertiesByAttribute(123, 'readOnly', mockPath, [], errorMessage)).toEqual([]); - expect(findPropertiesByAttribute(true, 'readOnly', mockPath, [], errorMessage)).toEqual([]); - }); - - it('detects direct attribute match', () => { - const schema = { - type: 'string', - readOnly: true, - }; - - const errors = findPropertiesByAttribute(schema, 'readOnly', mockPath, [], errorMessage); - - expect(errors).toHaveLength(1); - expect(errors[0]).toEqual({ - path: mockPath, - message: `${errorMessage} Found readOnly property at one of the inline schemas.`, - }); - }); - - it('detects properties with the specified attribute', () => { - const schema = { - type: 'object', - properties: { - id: { - type: 'string', - readOnly: true, - }, - name: { - type: 'string', - }, - password: { - type: 'string', - writeOnly: true, - }, - }, - }; - - // Testing readOnly detection - let errors = findPropertiesByAttribute(schema, 'readOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Found readOnly property at: id.'); - - // Testing writeOnly detection - errors = findPropertiesByAttribute(schema, 'writeOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Found writeOnly property at: password.'); - }); - - it('detects nested properties with the specified attribute', () => { - const schema = { - type: 'object', - properties: { - user: { - type: 'object', - properties: { - id: { - type: 'string', - readOnly: true, - }, - credentials: { - type: 'object', - properties: { - password: { - type: 'string', - writeOnly: true, - }, - }, - }, - }, - }, - }, - }; - - // Testing deep readOnly detection - let errors = findPropertiesByAttribute(schema, 'readOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Found readOnly property at: user.id.'); - - // Testing deep writeOnly detection - errors = findPropertiesByAttribute(schema, 'writeOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Found writeOnly property at: user.credentials.password.'); - }); - - it('detects properties in array items', () => { - const schema = { - type: 'object', - properties: { - items: { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'string', - readOnly: true, - }, - secret: { - type: 'string', - writeOnly: true, - }, - }, - }, - }, - }, - }; - - // Testing readOnly in array items - let errors = findPropertiesByAttribute(schema, 'readOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Found readOnly property at: items.items.id.'); - - // Testing writeOnly in array items - errors = findPropertiesByAttribute(schema, 'writeOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Found writeOnly property at: items.items.secret.'); - }); - - it('detects properties in schema combiners', () => { - const schema = { - allOf: [ - { - type: 'object', - properties: { - id: { - type: 'string', - readOnly: true, - }, - }, - }, - ], - anyOf: [ - { - type: 'object', - properties: { - key: { - type: 'string', - writeOnly: true, - }, - }, - }, - ], - oneOf: [ - { - type: 'object', - }, - { - type: 'object', - properties: { - token: { - type: 'string', - readOnly: true, - }, - }, - }, - ], - }; - - // Testing readOnly in combiners - let errors = findPropertiesByAttribute(schema, 'readOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(2); - expect(errors[0].message).toContain('Found readOnly property at: allOf.0.id.'); - expect(errors[1].message).toContain('Found readOnly property at: oneOf.1.token.'); - - // Testing writeOnly in combiners - errors = findPropertiesByAttribute(schema, 'writeOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Found writeOnly property at: anyOf.0.key.'); - }); - - it('correctly accumulates multiple errors', () => { - const schema = { - type: 'object', - properties: { - id: { - type: 'string', - readOnly: true, - }, - nested: { - type: 'object', - properties: { - innerId: { - type: 'string', - readOnly: true, - }, - }, - }, - items: { - type: 'array', - items: { - readOnly: true, - }, - }, - }, - }; - - const errors = findPropertiesByAttribute(schema, 'readOnly', mockPath, [], errorMessage); - - expect(errors).toHaveLength(3); - expect(errors[0].message).toContain('Found readOnly property at: id.'); - expect(errors[1].message).toContain('Found readOnly property at: nested.innerId.'); - expect(errors[2].message).toContain('Found readOnly property at: items.items.'); - }); - - it('handles empty objects', () => { - const schema = {}; - const errors = findPropertiesByAttribute(schema, 'readOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(0); - }); - - it('handles schemas with no matching attributes', () => { - const schema = { - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - nested: { - type: 'object', - properties: { - value: { type: 'number' }, - }, - }, - items: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - }; - - const errors = findPropertiesByAttribute(schema, 'readOnly', mockPath, [], errorMessage); - expect(errors).toHaveLength(0); - }); -}); diff --git a/tools/spectral/ipa/__tests__/utils/validations.test.js b/tools/spectral/ipa/__tests__/utils/validations.test.js new file mode 100644 index 0000000000..a542e5f2ad --- /dev/null +++ b/tools/spectral/ipa/__tests__/utils/validations.test.js @@ -0,0 +1,253 @@ +// schemaUtils.test.js +import { checkForbiddenPropertyAttributesAndReturnErrors } from '../../rulesets/functions/utils/validations.js'; +import { describe, expect, it } from '@jest/globals'; + +describe('tools/spectral/ipa/rulesets/functions/utils/validations.js', () => { + describe('checkForbiddenPropertyAttributesAndReturnErrors', () => { + const mockPath = ['paths', '/resources', 'get', 'responses', '200', 'content', 'application/json']; + const errorMessage = 'Test error message'; + + it('handles primitive values', () => { + expect(checkForbiddenPropertyAttributesAndReturnErrors(null, 'readOnly', mockPath, [], errorMessage)).toEqual([]); + expect( + checkForbiddenPropertyAttributesAndReturnErrors(undefined, 'readOnly', mockPath, [], errorMessage) + ).toEqual([]); + expect(checkForbiddenPropertyAttributesAndReturnErrors('string', 'readOnly', mockPath, [], errorMessage)).toEqual( + [] + ); + expect(checkForbiddenPropertyAttributesAndReturnErrors(123, 'readOnly', mockPath, [], errorMessage)).toEqual([]); + expect(checkForbiddenPropertyAttributesAndReturnErrors(true, 'readOnly', mockPath, [], errorMessage)).toEqual([]); + }); + + it('detects direct attribute match', () => { + const schema = { + type: 'string', + readOnly: true, + }; + + const errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'readOnly', mockPath, [], errorMessage); + + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ + path: mockPath, + message: `${errorMessage} Found readOnly property at one of the inline schemas.`, + }); + }); + + it('detects properties with the specified attribute', () => { + const schema = { + type: 'object', + properties: { + id: { + type: 'string', + readOnly: true, + }, + name: { + type: 'string', + }, + password: { + type: 'string', + writeOnly: true, + }, + }, + }; + + // Testing readOnly detection + let errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'readOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Found readOnly property at: id.'); + + // Testing writeOnly detection + errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'writeOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Found writeOnly property at: password.'); + }); + + it('detects nested properties with the specified attribute', () => { + const schema = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + id: { + type: 'string', + readOnly: true, + }, + credentials: { + type: 'object', + properties: { + password: { + type: 'string', + writeOnly: true, + }, + }, + }, + }, + }, + }, + }; + + // Testing deep readOnly detection + let errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'readOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Found readOnly property at: user.id.'); + + // Testing deep writeOnly detection + errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'writeOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Found writeOnly property at: user.credentials.password.'); + }); + + it('detects properties in array items', () => { + const schema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + readOnly: true, + }, + secret: { + type: 'string', + writeOnly: true, + }, + }, + }, + }, + }, + }; + + // Testing readOnly in array items + let errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'readOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Found readOnly property at: items.items.id.'); + + // Testing writeOnly in array items + errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'writeOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Found writeOnly property at: items.items.secret.'); + }); + + it('detects properties in schema combiners', () => { + const schema = { + allOf: [ + { + type: 'object', + properties: { + id: { + type: 'string', + readOnly: true, + }, + }, + }, + ], + anyOf: [ + { + type: 'object', + properties: { + key: { + type: 'string', + writeOnly: true, + }, + }, + }, + ], + oneOf: [ + { + type: 'object', + }, + { + type: 'object', + properties: { + token: { + type: 'string', + readOnly: true, + }, + }, + }, + ], + }; + + // Testing readOnly in combiners + let errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'readOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(2); + expect(errors[0].message).toContain('Found readOnly property at: allOf.0.id.'); + expect(errors[1].message).toContain('Found readOnly property at: oneOf.1.token.'); + + // Testing writeOnly in combiners + errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'writeOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Found writeOnly property at: anyOf.0.key.'); + }); + + it('correctly accumulates multiple errors', () => { + const schema = { + type: 'object', + properties: { + id: { + type: 'string', + readOnly: true, + }, + nested: { + type: 'object', + properties: { + innerId: { + type: 'string', + readOnly: true, + }, + }, + }, + items: { + type: 'array', + items: { + readOnly: true, + }, + }, + }, + }; + + const errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'readOnly', mockPath, [], errorMessage); + + expect(errors).toHaveLength(3); + expect(errors[0].message).toContain('Found readOnly property at: id.'); + expect(errors[1].message).toContain('Found readOnly property at: nested.innerId.'); + expect(errors[2].message).toContain('Found readOnly property at: items.items.'); + }); + + it('handles empty objects', () => { + const schema = {}; + const errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'readOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(0); + }); + + it('handles schemas with no matching attributes', () => { + const schema = { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + nested: { + type: 'object', + properties: { + value: { type: 'number' }, + }, + }, + items: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }; + + const errors = checkForbiddenPropertyAttributesAndReturnErrors(schema, 'readOnly', mockPath, [], errorMessage); + expect(errors).toHaveLength(0); + }); + }); +}); diff --git a/tools/spectral/ipa/rulesets/IPA-107.yaml b/tools/spectral/ipa/rulesets/IPA-107.yaml index 46cb11f42a..1c0af00483 100644 --- a/tools/spectral/ipa/rulesets/IPA-107.yaml +++ b/tools/spectral/ipa/rulesets/IPA-107.yaml @@ -5,6 +5,7 @@ functions: - IPA107UpdateMethodMustNotHaveQueryParams - IPA107UpdateResponseCodeShouldBe200OK - IPA107UpdateMethodResponseIsGetMethodResponse + - IPA107UpdateMethodRequestHasNoReadonlyFields rules: xgen-IPA-107-put-must-not-have-query-params: @@ -76,7 +77,7 @@ rules: ##### Implementation details Rule checks for the following conditions: - Applies only to single resource paths with JSON content types - - Ignores singleton resources and responses without a schema + - Ignores singleton resources and responses without a schema - Validation ignores resources without a Get method - Fails if the Get method doesn't have a schema reference or if the schemas don't match - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation @@ -86,3 +87,19 @@ rules: then: field: '@key' function: 'IPA107UpdateMethodResponseIsGetMethodResponse' + xgen-IPA-107-update-method-request-has-no-readonly-fields: + description: | + Update method Request object must not include fields with readOnly:true. + + ##### Implementation details + Rule checks for the following conditions: + - Applies only to Update methods on single resource paths + - Applies only to JSON content types + - Searches through the request schema to find any properties marked with readOnly attribute + - Fails if any readOnly properties are found in the request schema + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-107-update-method-request-has-no-readonly-fields' + severity: warn + given: '$.paths[*][put,patch].requestBody.content' + then: + field: '@key' + function: 'IPA107UpdateMethodRequestHasNoReadonlyFields' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 17fad68f9c..07f4f49980 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -319,10 +319,22 @@ Validation checks the PATCH method for single resource paths and [singleton reso The response body of the Update method should consist of the same resource object returned by the Get method. ##### Implementation details Rule checks for the following conditions: - Applies only to single resource paths with JSON content types - - Ignores singleton resources and responses without a schema + - Ignores singleton resources and responses without a schema - Validation ignores resources without a Get method - Fails if the Get method doesn't have a schema reference or if the schemas don't match - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation +#### xgen-IPA-107-update-method-request-has-no-readonly-fields + + ![warn](https://img.shields.io/badge/warning-yellow) +Update method Request object must not include fields with readOnly:true. + +##### Implementation details +Rule checks for the following conditions: + - Applies only to Update methods on single resource paths + - Applies only to JSON content types + - Searches through the request schema to find any properties marked with readOnly attribute + - Fails if any readOnly properties are found in the request schema + ### IPA-108 diff --git a/tools/spectral/ipa/rulesets/functions/IPA104GetMethodResponseHasNoInputFields.js b/tools/spectral/ipa/rulesets/functions/IPA104GetMethodResponseHasNoInputFields.js index 5fe7e5a012..ecae56e9aa 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA104GetMethodResponseHasNoInputFields.js +++ b/tools/spectral/ipa/rulesets/functions/IPA104GetMethodResponseHasNoInputFields.js @@ -7,7 +7,7 @@ import { isSingletonResource, } from './utils/resourceEvaluation.js'; import { resolveObject } from './utils/componentUtils.js'; -import { findPropertiesByAttribute } from './utils/schemaUtils.js'; +import { checkForbiddenPropertyAttributesAndReturnErrors } from './utils/validations.js'; const RULE_NAME = 'xgen-IPA-104-get-method-response-has-no-input-fields'; const ERROR_MESSAGE = 'The get method response object must not include output fields (writeOnly properties).'; @@ -35,14 +35,16 @@ export default (input, _, { path, documentInventory }) => { return; } - const errors = checkViolationsAndReturnErrors(contentPerMediaType, path); + const errors = checkForbiddenPropertyAttributesAndReturnErrors( + contentPerMediaType.schema, + 'writeOnly', + path, + [], + ERROR_MESSAGE + ); if (errors.length !== 0) { return collectAndReturnViolation(path, RULE_NAME, errors); } return collectAdoption(path, RULE_NAME); }; - -function checkViolationsAndReturnErrors(contentPerMediaType, path) { - return findPropertiesByAttribute(contentPerMediaType.schema, 'writeOnly', path, [], ERROR_MESSAGE); -} diff --git a/tools/spectral/ipa/rulesets/functions/IPA106CreateMethodRequestHasNoReadonlyFields.js b/tools/spectral/ipa/rulesets/functions/IPA106CreateMethodRequestHasNoReadonlyFields.js index 4c955b8607..ccd0a2c8ac 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA106CreateMethodRequestHasNoReadonlyFields.js +++ b/tools/spectral/ipa/rulesets/functions/IPA106CreateMethodRequestHasNoReadonlyFields.js @@ -7,7 +7,7 @@ import { import { resolveObject } from './utils/componentUtils.js'; import { hasException } from './utils/exceptions.js'; import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; -import { findPropertiesByAttribute } from './utils/schemaUtils.js'; +import { checkForbiddenPropertyAttributesAndReturnErrors } from './utils/validations.js'; const RULE_NAME = 'xgen-IPA-106-create-method-request-has-no-readonly-fields'; const ERROR_MESSAGE = 'The Create method request object must not include input fields (readOnly properties).'; @@ -34,7 +34,13 @@ export default (input, _, { path, documentInventory }) => { return; } - const errors = checkViolationsAndReturnErrors(requestContentPerMediaType, path); + const errors = checkForbiddenPropertyAttributesAndReturnErrors( + requestContentPerMediaType.schema, + 'readOnly', + path, + [], + ERROR_MESSAGE + ); if (errors.length !== 0) { return collectAndReturnViolation(path, RULE_NAME, errors); @@ -42,7 +48,3 @@ export default (input, _, { path, documentInventory }) => { collectAdoption(path, RULE_NAME); }; - -function checkViolationsAndReturnErrors(contentPerMediaType, path) { - return findPropertiesByAttribute(contentPerMediaType.schema, 'readOnly', path, [], ERROR_MESSAGE); -} diff --git a/tools/spectral/ipa/rulesets/functions/IPA107UpdateMethodRequestHasNoReadonlyFields.js b/tools/spectral/ipa/rulesets/functions/IPA107UpdateMethodRequestHasNoReadonlyFields.js new file mode 100644 index 0000000000..4a6cbe6177 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA107UpdateMethodRequestHasNoReadonlyFields.js @@ -0,0 +1,48 @@ +import { isSingleResourceIdentifier } from './utils/resourceEvaluation.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { hasException } from './utils/exceptions.js'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; +import { checkForbiddenPropertyAttributesAndReturnErrors } from './utils/validations.js'; + +const RULE_NAME = 'xgen-IPA-107-update-method-request-has-no-readonly-fields'; +const ERROR_MESSAGE = 'The Update method request object must not include input fields (readOnly properties).'; + +/** + * Update method (PUT, PATCH) request objects must not include input fields (readOnly properties). + * + * @param {object} input - An update operation request content version + * @param {object} _ - Unused + * @param {{ path: string[], documentInventory: object}} context - The context object containing the path and document + */ +export default (input, _, { path, documentInventory }) => { + const resourcePath = path[1]; + const oas = documentInventory.resolved; + + if (!isSingleResourceIdentifier(resourcePath) || !input.endsWith('json')) { + return; + } + + const requestContentPerMediaType = resolveObject(oas, path); + if (!requestContentPerMediaType || !requestContentPerMediaType.schema) { + return; + } + + if (hasException(requestContentPerMediaType, RULE_NAME)) { + collectException(requestContentPerMediaType, RULE_NAME, path); + return; + } + + const errors = checkForbiddenPropertyAttributesAndReturnErrors( + requestContentPerMediaType.schema, + 'readOnly', + path, + [], + ERROR_MESSAGE + ); + + if (errors.length !== 0) { + return collectAndReturnViolation(path, RULE_NAME, errors); + } + + collectAdoption(path, RULE_NAME); +}; diff --git a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js index 0bec1d4a4e..d70d323ba0 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js +++ b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js @@ -30,55 +30,3 @@ export function getSchemaPathFromEnumPath(path) { } return path.slice(0, enumIndex); } - -/** - * Recursively searches a schema to find properties with a specific attribute - * - * @param {Object} schema - The schema to check - * @param {string} attributeName - The attribute to check for (e.g. 'readOnly', 'writeOnly') - * @param {Array} path - The path to the current schema in the document - * @param {Array} errors - Accumulator for errors found - * @param {string} errorMessage - The base error message to use - * @param {Array} propPath - The current property path (for error messages) - * @returns {Array} The accumulated errors - */ -export function findPropertiesByAttribute(schema, attributeName, path, errors = [], errorMessage, propPath = []) { - if (!schema || typeof schema !== 'object') { - return errors; - } - - // Check if this schema has the attribute set to true - if (schema[attributeName] === true) { - errors.push({ - path, - message: - propPath.length > 0 - ? `${errorMessage} Found ${attributeName} property at: ${propPath.join('.')}.` - : `${errorMessage} Found ${attributeName} property at one of the inline schemas.`, - }); - return errors; - } - - // Check properties in object schemas - if (schema.properties) { - for (const [propName, propSchema] of Object.entries(schema.properties)) { - findPropertiesByAttribute(propSchema, attributeName, path, errors, errorMessage, [...propPath, propName]); - } - } - - // Check items in array schemas - if (schema.items) { - findPropertiesByAttribute(schema.items, attributeName, path, errors, errorMessage, [...propPath, 'items']); - } - - // Check allOf, anyOf, oneOf schemas - ['allOf', 'anyOf', 'oneOf'].forEach((combiner) => { - if (Array.isArray(schema[combiner])) { - schema[combiner].forEach((subSchema, index) => { - findPropertiesByAttribute(subSchema, attributeName, path, errors, errorMessage, [...propPath, combiner, index]); - }); - } - }); - - return errors; -} diff --git a/tools/spectral/ipa/rulesets/functions/utils/validations.js b/tools/spectral/ipa/rulesets/functions/utils/validations.js index 403cc0a52b..0e4062cfa9 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/validations.js +++ b/tools/spectral/ipa/rulesets/functions/utils/validations.js @@ -30,3 +30,73 @@ export function checkResponseCodeAndReturnErrors(operationObject, expectedStatus handleInternalError(ruleName, path, e); } } + +/** + * Recursively searches a schema to find properties with a specific attribute, + * and returns errors if such a property is found, ready to be used in a custom validation function. + * + * @param {Object} schema - The schema to check + * @param {string} attributeName - The attribute to check for (e.g. 'readOnly', 'writeOnly') + * @param {Array} path - The path to the current schema in the document + * @param {Array} errors - Accumulator for errors found + * @param {string} errorMessage - The base error message to use + * @param {Array} propPath - The current property path (for error messages) + * @returns {Array} The accumulated errors + */ +export function checkForbiddenPropertyAttributesAndReturnErrors( + schema, + attributeName, + path, + errors, + errorMessage, + propPath = [] +) { + if (!schema || typeof schema !== 'object') { + return errors; + } + + // Check if this schema has the attribute set to true + if (schema[attributeName] === true) { + errors.push({ + path, + message: + propPath.length > 0 + ? `${errorMessage} Found ${attributeName} property at: ${propPath.join('.')}.` + : `${errorMessage} Found ${attributeName} property at one of the inline schemas.`, + }); + return errors; + } + + // Check properties in object schemas + if (schema.properties) { + for (const [propName, propSchema] of Object.entries(schema.properties)) { + checkForbiddenPropertyAttributesAndReturnErrors(propSchema, attributeName, path, errors, errorMessage, [ + ...propPath, + propName, + ]); + } + } + + // Check items in array schemas + if (schema.items) { + checkForbiddenPropertyAttributesAndReturnErrors(schema.items, attributeName, path, errors, errorMessage, [ + ...propPath, + 'items', + ]); + } + + // Check allOf, anyOf, oneOf schemas + ['allOf', 'anyOf', 'oneOf'].forEach((combiner) => { + if (Array.isArray(schema[combiner])) { + schema[combiner].forEach((subSchema, index) => { + checkForbiddenPropertyAttributesAndReturnErrors(subSchema, attributeName, path, errors, errorMessage, [ + ...propPath, + combiner, + index, + ]); + }); + } + }); + + return errors; +}