diff --git a/tools/spectral/ipa/__tests__/IPA106ReadOnlyResourceShouldNotHaveCreateMethod.test.js b/tools/spectral/ipa/__tests__/IPA106ReadOnlyResourceShouldNotHaveCreateMethod.test.js new file mode 100644 index 0000000000..f3bde92bd9 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA106ReadOnlyResourceShouldNotHaveCreateMethod.test.js @@ -0,0 +1,290 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-106-readonly-resource-should-not-have-create-method', [ + { + name: 'valid: writable resource with create method', + document: { + openapi: '3.0.0', + paths: { + '/writableResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, // Not readOnly - writable resource + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/writableResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid: read-only resource with POST method', + document: { + openapi: '3.0.0', + paths: { + '/readOnlyResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/readOnlyResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-106-readonly-resource-should-not-have-create-method', + message: 'Read-only resources must not define the Create method.', + path: ['paths', '/readOnlyResource', 'post'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'valid: read-only resource with create method and exception', + document: { + openapi: '3.0.0', + paths: { + '/readOnlyResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + post: { + 'x-xgen-IPA-exception': { + 'xgen-IPA-106-readonly-resource-should-not-have-create-method': 'Special case exception', + }, + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/readOnlyResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid: resource without GET method should not error', + document: { + openapi: '3.0.0', + paths: { + '/resource': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA107ReadOnlyResourceShouldNotHaveUpdateMethod.test.js b/tools/spectral/ipa/__tests__/IPA107ReadOnlyResourceShouldNotHaveUpdateMethod.test.js new file mode 100644 index 0000000000..e96bbeffba --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA107ReadOnlyResourceShouldNotHaveUpdateMethod.test.js @@ -0,0 +1,329 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-107-readonly-resource-should-not-have-update-method', [ + { + name: 'valid: writable resource with update method', + document: { + paths: { + '/resource': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + patch: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid: read-only resource with PATCH method', + document: { + paths: { + '/readOnlyResource': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + updatedAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + '/readOnlyResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + updatedAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + patch: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + updatedAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-107-readonly-resource-should-not-have-update-method', + message: 'Read-only resources must not define the Update method.', + path: ['paths', '/readOnlyResource/{id}', 'patch'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid: read-only resource with PUT method', + document: { + paths: { + '/readOnlyResource': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + '/readOnlyResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + put: { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-107-readonly-resource-should-not-have-update-method', + message: 'Read-only resources must not define the Update method.', + path: ['paths', '/readOnlyResource/{id}', 'put'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'valid: read-only resource with update method and exception', + document: { + paths: { + '/readOnlyResource': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + '/readOnlyResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + patch: { + 'x-xgen-IPA-exception': { + 'xgen-IPA-107-readonly-resource-should-not-have-update-method': 'Special case exception', + }, + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA108ReadOnlyResourceShouldNotHaveDeleteMethod.test.js b/tools/spectral/ipa/__tests__/IPA108ReadOnlyResourceShouldNotHaveDeleteMethod.test.js new file mode 100644 index 0000000000..645493e871 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA108ReadOnlyResourceShouldNotHaveDeleteMethod.test.js @@ -0,0 +1,243 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-108-readonly-resource-should-not-have-delete-method', [ + { + name: 'valid: writable resource with delete method', + document: { + openapi: '3.0.0', + paths: { + '/writableResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, // Not readOnly - writable resource + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/writableResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + delete: { + responses: { + 204: { + description: 'No Content', + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid: read-only resource with DELETE method', + document: { + openapi: '3.0.0', + paths: { + '/readOnlyResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + '/readOnlyResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + delete: { + responses: { + 204: { + description: 'No Content', + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-108-readonly-resource-should-not-have-delete-method', + message: 'Read-only resources must not define the Delete method.', + path: ['paths', '/readOnlyResource/{id}', 'delete'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'valid: read-only resource with delete method and exception', + document: { + openapi: '3.0.0', + paths: { + '/readOnlyResource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + '/readOnlyResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + delete: { + 'x-xgen-IPA-exception': { + 'xgen-IPA-108-readonly-resource-should-not-have-delete-method': 'Special case exception', + }, + responses: { + 204: { + description: 'No Content', + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid: read-only singleton resource with DELETE method', + document: { + openapi: '3.0.0', + paths: { + '/parent/{parentId}/readOnlySingleton': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + lastUpdated: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + delete: { + responses: { + 204: { + description: 'No Content', + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-108-readonly-resource-should-not-have-delete-method', + message: 'Read-only resources must not define the Delete method.', + path: ['paths', '/parent/{parentId}/readOnlySingleton', 'delete'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'valid: resource without GET method should not error', + document: { + openapi: '3.0.0', + paths: { + '/resource/{id}': { + delete: { + responses: { + 204: { + description: 'No Content', + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA113SingletonHasUpdateMethod.test.js b/tools/spectral/ipa/__tests__/IPA113SingletonHasUpdateMethod.test.js index e47320cb3a..c4a517e5bf 100644 --- a/tools/spectral/ipa/__tests__/IPA113SingletonHasUpdateMethod.test.js +++ b/tools/spectral/ipa/__tests__/IPA113SingletonHasUpdateMethod.test.js @@ -66,4 +66,32 @@ testRule('xgen-IPA-113-singleton-should-have-update-method', [ }, errors: [], }, + { + name: 'read-only singleton resources do not require update method', + document: { + paths: { + '/resource/{exampleId}/readOnlySingleton': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + updatedAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, ]); diff --git a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js index 6e791f898a..2a404f9359 100644 --- a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js +++ b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js @@ -1,6 +1,8 @@ import { describe, expect, it } from '@jest/globals'; import { + allPropertiesAreReadOnly, getResourcePathItems, + isReadOnlyResource, isResourceCollectionIdentifier, isSingleResourceIdentifier, isSingletonResource, @@ -9,10 +11,47 @@ import { const resource = { '/resource': { post: {}, - get: {}, + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, }, '/resource/{id}': { - get: {}, + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, patch: {}, delete: {}, }, @@ -45,11 +84,51 @@ const childResource = { const singleton = { '/resource/{id}/singleton': { - get: {}, + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + enabled: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, patch: {}, }, }; +const readOnlySingleton = { + '/resource/{id}/readOnlySingleton': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + updatedAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, +}; + const customMethodResource = { '/custom': { post: {}, @@ -68,6 +147,55 @@ const customMethodResource = { }, }; +const readOnlyResource = { + '/readOnlyResource': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + '/readOnlyResource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +const resourceWithoutGetMethod = { + '/resourceWithoutGet': { + post: {}, + }, +}; + const mockOas = { paths: { ...resource, @@ -75,7 +203,10 @@ const mockOas = { ...resourceMissingMethods, ...childResourceMissingSubPath, ...singleton, + ...readOnlySingleton, ...customMethodResource, + ...readOnlyResource, + ...resourceWithoutGetMethod, }, }; @@ -107,6 +238,11 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () resourcePathItems: singleton, isSingletonResource: true, }, + { + description: 'read-only singleton resource', + resourcePathItems: readOnlySingleton, + isSingletonResource: true, + }, { description: 'standard resource with custom methods', resourcePathItems: customMethodResource, @@ -138,6 +274,11 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () path: '/resource/{id}/singleton', expectedResourcePathItems: singleton, }, + { + description: 'read-only singleton resource', + path: '/resource/{id}/readOnlySingleton', + expectedResourcePathItems: readOnlySingleton, + }, { description: 'resource with custom methods', path: '/custom', @@ -250,4 +391,144 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () }); }); }); + + describe('allPropertiesAreReadOnly', () => { + const testCases = [ + { + description: 'schema with all properties readOnly', + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + createdAt: { type: 'string', readOnly: true }, + }, + }, + expected: true, + }, + { + description: 'schema with some properties not readOnly', + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + }, + expected: false, + }, + { + description: 'schema with no properties', + schema: { + type: 'object', + }, + expected: false, + }, + { + description: 'schema with nested object all readOnly', + schema: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + metadata: { + type: 'object', + readOnly: true, + properties: { + createdBy: { type: 'string', readOnly: true }, + updatedBy: { type: 'string', readOnly: true }, + }, + }, + }, + }, + expected: true, + }, + { + description: 'schema with array items all readOnly', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + name: { type: 'string', readOnly: true }, + }, + }, + }, + expected: true, + }, + { + description: 'schema with allOf all readOnly', + schema: { + allOf: [ + { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + }, + }, + { + type: 'object', + properties: { + name: { type: 'string', readOnly: true }, + }, + }, + ], + }, + expected: true, + }, + { + description: 'null schema', + schema: null, + expected: false, + }, + { + description: 'undefined schema', + schema: undefined, + expected: false, + }, + ]; + + testCases.forEach((testCase) => { + it(`returns ${testCase.expected} for ${testCase.description}`, () => { + expect(allPropertiesAreReadOnly(testCase.schema)).toEqual(testCase.expected); + }); + }); + }); + + describe('isReadOnlyResource', () => { + const testCases = [ + { + description: 'read-only resource', + resourcePathItems: readOnlyResource, + expected: true, + }, + { + description: 'resource without GET method', + resourcePathItems: resourceWithoutGetMethod, + expected: false, + }, + { + description: 'standard resource with mixed readOnly properties', + resourcePathItems: resource, + expected: false, + }, + { + description: 'singleton resource (writable)', + resourcePathItems: singleton, + expected: false, + }, + { + description: 'read-only singleton resource', + resourcePathItems: readOnlySingleton, + expected: true, + }, + ]; + + testCases.forEach((testCase) => { + it(`returns ${testCase.expected} for ${testCase.description}`, () => { + expect(isReadOnlyResource(testCase.resourcePathItems)).toEqual(testCase.expected); + }); + }); + }); }); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index df7ab83867..59b1ce128b 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -106,3 +106,33 @@ overrides: - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1limits~1%7BlimitName%7D/patch/parameters/0' rules: xgen-IPA-117-description-should-not-use-inline-tables: 'off' + - files: + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1apiKeys/post' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1databaseUsers/post' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1databaseUsers~1%7Busername%7D~1certs/post' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1liveMigrations/post' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1privateEndpoint~1%7BcloudProvider%7D~1endpointService~1%7BendpointServiceId%7D~1endpoint/post' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1privateEndpoint~1serverless~1instance~1%7BinstanceName%7D~1endpoint/post' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1serviceAccounts~1%7BclientId%7D~1accessList/post' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1users/post' + - '**#/paths/~1api~1atlas~1v2~1orgs~1%7BorgId%7D~1resourcePolicies/post' + - '**#/paths/~1api~1atlas~1v2~1orgs~1%7BorgId%7D~1serviceAccounts~1%7BclientId%7D~1accessList/post' + - '**#/paths/~1api~1atlas~1v2~1orgs~1%7BorgId%7D~1teams~1%7BteamId%7D~1users/post' + - '**#/paths/~1api~1atlas~1v2~1orgs~1%7BorgId%7D~1users/post' + rules: + xgen-IPA-106-readonly-resource-should-not-have-create-method: 'off' + - files: + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1privateEndpoint~1%7BcloudProvider%7D~1endpointService~1%7BendpointServiceId%7D/delete' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1privateEndpoint~1%7BcloudProvider%7D~1endpointService~1%7BendpointServiceId%7D~1endpoint~1%7BendpointId%7D/delete' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1privateEndpoint~1serverless~1instance~1%7BinstanceName%7D~1endpoint~1%7BendpointId%7D/delete' + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1users~1%7BuserId%7D/delete' + - '**#/paths/~1api~1atlas~1v2~1orgs~1%7BorgId%7D~1resourcePolicies~1%7BresourcePolicyId%7D/delete' + - '**#/paths/~1api~1atlas~1v2~1orgs~1%7BorgId%7D~1users~1%7BuserId%7D/delete' + rules: + xgen-IPA-108-readonly-resource-should-not-have-delete-method: 'off' + - files: + - '**#/paths/~1api~1atlas~1v2~1groups~1%7BgroupId%7D~1privateEndpoint~1serverless~1instance~1%7BinstanceName%7D~1endpoint~1%7BendpointId%7D/patch' + - '**#/paths/~1api~1atlas~1v2~1orgs~1%7BorgId%7D~1resourcePolicies~1%7BresourcePolicyId%7D/patch' + - '**#/paths/~1api~1atlas~1v2~1orgs~1%7BorgId%7D~1users~1%7BuserId%7D/patch' + rules: + xgen-IPA-107-readonly-resource-should-not-have-update-method: 'off' diff --git a/tools/spectral/ipa/rulesets/IPA-106.yaml b/tools/spectral/ipa/rulesets/IPA-106.yaml index f9bd8e4d0d..eed4b2c3a8 100644 --- a/tools/spectral/ipa/rulesets/IPA-106.yaml +++ b/tools/spectral/ipa/rulesets/IPA-106.yaml @@ -8,6 +8,7 @@ functions: - IPA106CreateMethodRequestHasNoReadonlyFields - IPA106CreateMethodResponseCodeIs201Created - IPA106CreateMethodResponseIsGetMethodResponse + - IPA106ReadOnlyResourceShouldNotHaveCreateMethod - IPA106ValidOperationID aliases: @@ -113,6 +114,21 @@ rules: then: field: '@key' function: 'IPA106CreateMethodResponseIsGetMethodResponse' + xgen-IPA-106-readonly-resource-should-not-have-create-method: + description: | + Read-only resources must not define the Create method. + + ##### Implementation details + Rule checks for the following conditions: + - Applies to POST methods on resource collection paths + - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Fails if a Create method is defined on a read-only resource + - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-106-readonly-resource-should-not-have-create-method' + severity: error + given: '#CreateOperationObject' + then: + function: 'IPA106ReadOnlyResourceShouldNotHaveCreateMethod' xgen-IPA-106-valid-operation-id: description: | The Operation ID must start with the verb “create” and should be followed by a noun or compound noun. diff --git a/tools/spectral/ipa/rulesets/IPA-107.yaml b/tools/spectral/ipa/rulesets/IPA-107.yaml index ca7d2d6721..d0d91373a3 100644 --- a/tools/spectral/ipa/rulesets/IPA-107.yaml +++ b/tools/spectral/ipa/rulesets/IPA-107.yaml @@ -8,6 +8,7 @@ functions: - IPA107UpdateMethodRequestHasNoReadonlyFields - IPA107UpdateMethodRequestBodyIsGetResponse - IPA107UpdateMethodRequestBodyIsUpdateRequestSuffixedObject + - IPA107ReadOnlyResourceShouldNotHaveUpdateMethod - IPA107ValidOperationID aliases: @@ -113,6 +114,21 @@ rules: then: field: '@key' function: 'IPA107UpdateMethodRequestBodyIsUpdateRequestSuffixedObject' + xgen-IPA-107-readonly-resource-should-not-have-update-method: + description: | + Read-only resources must not define the Update method. + + ##### Implementation details + Rule checks for the following conditions: + - Applies to PUT/PATCH methods on all resource paths + - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Fails if an Update method is defined on a read-only resource + - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-107-readonly-resource-should-not-have-update-method' + severity: error + given: '#UpdateOperationObject' + then: + function: 'IPA107ReadOnlyResourceShouldNotHaveUpdateMethod' xgen-IPA-107-valid-operation-id: description: | The Operation ID must start with the verb “update” and should be followed by a noun or compound noun. diff --git a/tools/spectral/ipa/rulesets/IPA-108.yaml b/tools/spectral/ipa/rulesets/IPA-108.yaml index 94538a0345..f69e264709 100644 --- a/tools/spectral/ipa/rulesets/IPA-108.yaml +++ b/tools/spectral/ipa/rulesets/IPA-108.yaml @@ -55,6 +55,21 @@ rules: given: '#DeleteOperationObject' then: function: IPA108DeleteMethodNoRequestBody + xgen-IPA-108-readonly-resource-should-not-have-delete-method: + description: | + Read-only resources must not define the Delete method. + + ##### Implementation details + Rule checks for the following conditions: + - Applies to DELETE methods on single resource paths and singleton resources + - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Fails if a Delete method is defined on a read-only resource + - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-108-readonly-resource-should-not-have-delete-method' + severity: error + given: '#DeleteOperationObject' + then: + function: 'IPA108ReadOnlyResourceShouldNotHaveDeleteMethod' xgen-IPA-108-valid-operation-id: description: | The Operation ID must start with the verb “delete” and should be followed by a noun or compound noun. @@ -82,4 +97,5 @@ functions: - IPA108DeleteMethodResponseShouldNotHaveSchema - IPA108DeleteMethod204Response - IPA108DeleteMethodNoRequestBody + - IPA108ReadOnlyResourceShouldNotHaveDeleteMethod - IPA108ValidOperationID diff --git a/tools/spectral/ipa/rulesets/IPA-113.yaml b/tools/spectral/ipa/rulesets/IPA-113.yaml index 07c9c8d1c3..63b203edc6 100644 --- a/tools/spectral/ipa/rulesets/IPA-113.yaml +++ b/tools/spectral/ipa/rulesets/IPA-113.yaml @@ -43,6 +43,7 @@ rules: ##### Implementation details Rule checks for the following conditions: - Applies only to singleton resources + - Excludes read-only singleton resources (where all properties in the GET response schema are marked as readOnly) - Checks that the resource has the PUT and/or PATCH methods defined message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-113-singleton-should-have-update-method' severity: error diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index b9c9989c55..38350984f4 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -312,6 +312,18 @@ Rule checks for the following conditions: - Ignores resources without a Get method - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation +#### xgen-IPA-106-readonly-resource-should-not-have-create-method + + ![error](https://img.shields.io/badge/error-red) +Read-only resources must not define the Create method. + +##### Implementation details +Rule checks for the following conditions: + - Applies to POST methods on resource collection paths + - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Fails if a Create method is defined on a read-only resource + - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation + #### xgen-IPA-106-valid-operation-id ![error](https://img.shields.io/badge/error-red) @@ -399,6 +411,18 @@ Rule checks for the following conditions: - Validation only applies to schema references to a predefined schema (not inline) - Confirms the referenced schema name ends with "Request" suffix +#### xgen-IPA-107-readonly-resource-should-not-have-update-method + + ![error](https://img.shields.io/badge/error-red) +Read-only resources must not define the Update method. + +##### Implementation details +Rule checks for the following conditions: + - Applies to PUT/PATCH methods on all resource paths + - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Fails if an Update method is defined on a read-only resource + - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation + #### xgen-IPA-107-valid-operation-id ![error](https://img.shields.io/badge/error-red) @@ -460,6 +484,18 @@ Rule checks for the following conditions: - Fails if any requestBody is defined for the DELETE method - Skips validation for collection endpoints (without path parameters) +#### xgen-IPA-108-readonly-resource-should-not-have-delete-method + + ![error](https://img.shields.io/badge/error-red) +Read-only resources must not define the Delete method. + +##### Implementation details +Rule checks for the following conditions: + - Applies to DELETE methods on single resource paths and singleton resources + - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Fails if a Delete method is defined on a read-only resource + - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation + #### xgen-IPA-108-valid-operation-id ![error](https://img.shields.io/badge/error-red) @@ -693,6 +729,7 @@ Singleton resources should define the Update method. Validation for the presence ##### Implementation details Rule checks for the following conditions: - Applies only to singleton resources + - Excludes read-only singleton resources (where all properties in the GET response schema are marked as readOnly) - Checks that the resource has the PUT and/or PATCH methods defined diff --git a/tools/spectral/ipa/rulesets/functions/IPA106ReadOnlyResourceShouldNotHaveCreateMethod.js b/tools/spectral/ipa/rulesets/functions/IPA106ReadOnlyResourceShouldNotHaveCreateMethod.js new file mode 100644 index 0000000000..ab9344f013 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA106ReadOnlyResourceShouldNotHaveCreateMethod.js @@ -0,0 +1,37 @@ +import { + getResourcePathItems, + isCustomMethodIdentifier, + isReadOnlyResource, + isResourceCollectionIdentifier, + isSingletonResource, +} from './utils/resourceEvaluation.js'; +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; + +const ERROR_MESSAGE = 'Read-only resources must not define the Create method.'; + +export default (input, opts, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const resourcePath = path[1]; + const oas = documentInventory.resolved; + const resourcePaths = getResourcePathItems(resourcePath, oas.paths); + + const isResourceCollection = isResourceCollectionIdentifier(resourcePath) && !isSingletonResource(resourcePaths); + if (isCustomMethodIdentifier(resourcePath) || !isResourceCollection) { + return; + } + + if (!isReadOnlyResource(resourcePaths)) { + return; + } + + const errors = checkViolationsAndReturnErrors(path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(path, ruleName) { + try { + return [{ path, message: ERROR_MESSAGE }]; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA107ReadOnlyResourceShouldNotHaveUpdateMethod.js b/tools/spectral/ipa/rulesets/functions/IPA107ReadOnlyResourceShouldNotHaveUpdateMethod.js new file mode 100644 index 0000000000..fbb730bb50 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA107ReadOnlyResourceShouldNotHaveUpdateMethod.js @@ -0,0 +1,39 @@ +import { + getResourcePathItems, + isReadOnlyResource, + isResourceCollectionIdentifier, + isSingleResourceIdentifier, + isSingletonResource, +} from './utils/resourceEvaluation.js'; +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; + +const ERROR_MESSAGE = 'Read-only resources must not define the Update method.'; + +export default (input, opts, { path, documentInventory, rule }) => { + const resourcePath = path[1]; + const oas = documentInventory.resolved; + const ruleName = rule.name; + const resourcePathItems = getResourcePathItems(resourcePath, oas.paths); + + if ( + !isSingleResourceIdentifier(resourcePath) && + !(isResourceCollectionIdentifier(resourcePath) && isSingletonResource(resourcePathItems)) + ) { + return; + } + + if (!isReadOnlyResource(resourcePathItems)) { + return; + } + + const errors = checkViolationsAndReturnErrors(path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(path, ruleName) { + try { + return [{ path, message: ERROR_MESSAGE }]; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA108ReadOnlyResourceShouldNotHaveDeleteMethod.js b/tools/spectral/ipa/rulesets/functions/IPA108ReadOnlyResourceShouldNotHaveDeleteMethod.js new file mode 100644 index 0000000000..1593cb35eb --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA108ReadOnlyResourceShouldNotHaveDeleteMethod.js @@ -0,0 +1,39 @@ +import { + getResourcePathItems, + isReadOnlyResource, + isResourceCollectionIdentifier, + isSingleResourceIdentifier, + isSingletonResource, +} from './utils/resourceEvaluation.js'; +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; + +const ERROR_MESSAGE = 'Read-only resources must not define the Delete method.'; + +export default (input, opts, { path, documentInventory, rule }) => { + const resourcePath = path[1]; + const oas = documentInventory.resolved; + const ruleName = rule.name; + const resourcePathItems = getResourcePathItems(resourcePath, oas.paths); + + if ( + !isSingleResourceIdentifier(resourcePath) && + !(isResourceCollectionIdentifier(resourcePath) && isSingletonResource(resourcePathItems)) + ) { + return; + } + + if (!isReadOnlyResource(resourcePathItems)) { + return; + } + + const errors = checkViolationsAndReturnErrors(path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(path, ruleName) { + try { + return [{ path, message: ERROR_MESSAGE }]; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA113SingletonHasUpdateMethod.js b/tools/spectral/ipa/rulesets/functions/IPA113SingletonHasUpdateMethod.js index fd79bc43d8..2a5f2760c1 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA113SingletonHasUpdateMethod.js +++ b/tools/spectral/ipa/rulesets/functions/IPA113SingletonHasUpdateMethod.js @@ -2,6 +2,7 @@ import { getResourcePathItems, hasPatchMethod, hasPutMethod, + isReadOnlyResource, isResourceCollectionIdentifier, isSingletonResource, } from './utils/resourceEvaluation.js'; @@ -20,6 +21,10 @@ export default (input, opts, { path, documentInventory, rule }) => { return; } + if (isReadOnlyResource(resourcePathItems)) { + return; + } + const errors = checkViolationsAndReturnErrors(input, path, ruleName); return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); }; diff --git a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js index 9d6888b8fd..3d4179c0c2 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js +++ b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js @@ -203,3 +203,109 @@ export function removePrefix(path) { } return path; } + +/** + * Checks if all properties in a schema have readOnly: true. + * + * @param {Object} schema - The schema to check + * @returns {boolean} true if all properties are readOnly, false otherwise + */ +export function allPropertiesAreReadOnly(schema) { + if (!schema || typeof schema !== 'object') { + return false; + } + + if (schema.properties) { + for (const [, propSchema] of Object.entries(schema.properties)) { + if (propSchema.readOnly !== true) { + return false; + } + } + return Object.keys(schema.properties).length > 0; + } + + if (schema.items) { + return allPropertiesAreReadOnly(schema.items); + } + + if (Array.isArray(schema.allOf)) { + return schema.allOf.every((subSchema) => allPropertiesAreReadOnly(subSchema)); + } + + if (Array.isArray(schema.anyOf)) { + return schema.anyOf.some((subSchema) => allPropertiesAreReadOnly(subSchema)); + } + + if (Array.isArray(schema.oneOf)) { + return schema.oneOf.some((subSchema) => allPropertiesAreReadOnly(subSchema)); + } + + return false; +} + +/** + * Checks if a resource is a read-only resource + * A read-only resource has all properties in its GET response schema marked as readOnly: true. + * + * @param {Object} resourcePathItems - All path items for the resource to be evaluated + * @returns {boolean} true if the resource is read-only, false otherwise + */ +export function isReadOnlyResource(resourcePathItems) { + const resourcePaths = Object.keys(resourcePathItems); + + // First, look for a standard Get method + let getPathItem = null; + for (const path of resourcePaths) { + if (isSingleResourceIdentifier(path) && hasGetMethod(resourcePathItems[path])) { + getPathItem = resourcePathItems[path]; + break; + } + } + + // If not found, look for Singleton Get method + if (!getPathItem) { + for (const path of resourcePaths) { + if (hasGetMethod(resourcePathItems[path])) { + getPathItem = resourcePathItems[path]; + break; + } + } + } + + if (!getPathItem || !getPathItem.get) { + return false; + } + + const getMethod = getPathItem.get; + if (!getMethod.responses) { + return false; + } + + const successfulResponseKey = Object.keys(getMethod.responses).find((k) => k.startsWith('2')); + if (!successfulResponseKey) { + return false; + } + + const response = getMethod.responses[successfulResponseKey]; + if (!response || !response.content) { + return false; + } + + const contentTypes = Object.keys(response.content); + if (contentTypes.length === 0) { + return false; + } + + for (const mediaType of contentTypes) { + const mediaTypeObj = response.content[mediaType]; + if (!mediaTypeObj || !mediaTypeObj.schema) { + continue; + } + + if (!allPropertiesAreReadOnly(mediaTypeObj.schema)) { + return false; + } + } + + return true; +}