diff --git a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js new file mode 100644 index 0000000000..ce8bfbeaf1 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js @@ -0,0 +1,202 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const READ_ONLY_SCHEMA_ERROR_MESSAGE = + 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.'; + +const readOnlyGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + properties: { + id: { readOnly: true }, + status: { readOnly: true }, + }, + }, + }, + }, + }, + }, +}; + +const nonReadOnlyGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + properties: { + id: { readOnly: true }, + status: { type: 'string' }, + }, + }, + }, + }, + }, + }, +}; + +const readOnlyOperationsEndpoint = { get: readOnlyGet }; +const nonReadOnlyOperationsEndpoint = { get: nonReadOnlyGet }; + +testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ + { + name: 'valid read-only Operations endpoints', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': readOnlyOperationsEndpoint, + '/api/atlas/v2/resourceName/operations/{operationId}': readOnlyOperationsEndpoint, + '/api/atlas/v2/resourceName/{pathParam}/operations': readOnlyOperationsEndpoint, + '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': readOnlyOperationsEndpoint, + }, + }, + errors: [], + }, + { + name: 'paths that are not Operations endpoints are ignored', + document: { + paths: { + '/api/atlas/v2/resourceName': { + post: {}, + }, + '/api/atlas/v2/resourceName/{pathParam}': { + put: {}, + patch: {}, + delete: {}, + }, + }, + }, + errors: [], + }, + { + name: 'invalid Operations endpoints with methods other than get', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + ...readOnlyOperationsEndpoint, + post: {}, + }, + '/api/atlas/v2/resourceName/operations/{operationId}': { + ...readOnlyOperationsEndpoint, + put: {}, + patch: {}, + delete: {}, + head: {}, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the post method.', + path: ['paths', '/api/atlas/v2/resourceName/operations', 'post'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the put method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'put'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the patch method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'patch'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the delete method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'delete'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the head method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'head'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid Operations endpoint with a declared but empty method', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + ...readOnlyOperationsEndpoint, + post: null, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the post method.', + path: ['paths', '/api/atlas/v2/resourceName/operations', 'post'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid single Operation endpoint with properties that are not readOnly', + document: { + paths: { + // The List method on the collection reuses the Operation resource schema, so only the + // single Operation endpoint, where the Get method is defined, is checked + '/api/atlas/v2/resourceName/operations': nonReadOnlyOperationsEndpoint, + '/api/atlas/v2/resourceName/operations/{operationId}': nonReadOnlyOperationsEndpoint, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: READ_ONLY_SCHEMA_ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'get'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid Operations endpoints with exceptions', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + ...readOnlyOperationsEndpoint, + post: {}, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-must-be-a-read-only-resource': 'reason', + }, + }, + }, + }, + errors: [], + }, + { + name: 'read-only Operations endpoints do not need an exception', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + ...readOnlyOperationsEndpoint, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-must-be-a-read-only-resource': 'reason', + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operation-must-be-a-read-only-resource', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js new file mode 100644 index 0000000000..fc9299555c --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js @@ -0,0 +1,111 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const ERROR_MESSAGE = + 'Operations endpoints must be leaf resources. An `operations` segment may only be followed by a single operation identifier path parameter.'; + +testRule('xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', [ + { + name: 'valid leaf Operations endpoints', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': {}, + '/api/atlas/v2/resourceName/operations/{operationId}': {}, + '/api/atlas/v2/resourceName/{pathParam}/operations': {}, + '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': {}, + // Root-level Operations endpoints are leaves, their nesting is covered by + // xgen-IPA-132-operations-endpoint-must-not-be-global + '/api/atlas/v2/operations': {}, + }, + }, + errors: [], + }, + { + name: 'paths without an operations segment are ignored', + document: { + paths: { + '/api/atlas/v2/resourceName': {}, + '/api/atlas/v2/resourceName/{pathParam}': {}, + '/api/atlas/v2/resourceName/{pathParam}/childResource': {}, + }, + }, + errors: [], + }, + { + name: 'invalid paths nested below Operations endpoints', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/subresource': {}, + '/api/atlas/v2/resourceName/operations/{operationId}/subresource': {}, + '/api/atlas/v2/resourceName/operations/{operationId}/{anotherId}': {}, + // Nesting below the first operations segment is a violation even when the path ends in + // another well-formed operations suffix + '/api/atlas/v2/resourceName/operations/{operationId}/operations': {}, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/subresource'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/subresource'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/{anotherId}'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/operations'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid paths with exceptions', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}/subresource': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource': 'reason', + }, + }, + }, + }, + errors: [], + }, + { + name: 'leaf Operations endpoints do not need an exception', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource': 'reason', + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations/{operationId}', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js new file mode 100644 index 0000000000..d2797ced1b --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js @@ -0,0 +1,81 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const ERROR_MESSAGE = + 'Operations endpoints must not be standalone, global endpoints with no parent resource in their path.'; + +testRule('xgen-IPA-132-operations-endpoint-must-not-be-global', [ + { + name: 'valid nested Operations endpoints', + document: { + paths: { + // Collection-scoped Operations endpoints, nested directly under the parent collection + '/api/atlas/v2/resourceName/operations': {}, + '/api/atlas/v2/resourceName/operations/{operationId}': {}, + '/api/atlas/v2/resourceName1/{pathParam}/resourceName2/operations': {}, + '/api/atlas/v2/resourceName1/{pathParam}/resourceName2/operations/{operationId}': {}, + '/api/atlas/v2/unauth/resourceName/operations': {}, + '/api/atlas/v2/unauth/resourceName/operations/{operationId}': {}, + // Instance-scoped Operations endpoints, nested under the parent resource instance + '/api/atlas/v2/resourceName/{pathParam}/operations': {}, + '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': {}, + }, + }, + errors: [], + }, + { + name: 'paths that are not Operations endpoints are ignored', + document: { + paths: { + '/api/atlas/v2/resourceName': {}, + '/api/atlas/v2/resourceName/{pathParam}': {}, + // Not a well-formed Operations endpoint, covered by xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource + '/api/atlas/v2/resourceName/operations/subresource': {}, + }, + }, + errors: [], + }, + { + name: 'invalid root-level Operations endpoints', + document: { + paths: { + '/api/atlas/v2/operations': {}, + '/api/atlas/v2/operations/{operationId}': {}, + '/api/atlas/v2/unauth/operations': {}, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/operations'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/operations/{operationId}'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/unauth/operations'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid root-level Operations endpoints with exceptions', + document: { + paths: { + '/api/atlas/v2/operations': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-endpoint-must-not-be-global': 'reason', + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js new file mode 100644 index 0000000000..461e303c0b --- /dev/null +++ b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js @@ -0,0 +1,91 @@ +import { describe, expect, it } from '@jest/globals'; +import { + containsOperationsSegment, + isOperationsCollectionPath, + isOperationsPath, + isSingleOperationPath, + operationsSegmentIsLeaf, +} from '../../rulesets/functions/utils/longRunningOperations'; + +describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { + describe('isOperationsCollectionPath', () => { + it('recognizes Operations resource collections', () => { + expect(isOperationsCollectionPath('/api/atlas/v2/groups/{groupId}/clusters/operations')).toBe(true); + expect(isOperationsCollectionPath('/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations')).toBe(true); + expect(isOperationsCollectionPath('/api/atlas/v2/unauth/resourceName/operations')).toBe(true); + expect(isOperationsCollectionPath('/api/atlas/v2/operations')).toBe(true); + }); + + it('rejects other paths', () => { + expect(isOperationsCollectionPath('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(false); + expect(isOperationsCollectionPath('/api/atlas/v2/resourceName')).toBe(false); + expect(isOperationsCollectionPath('/api/atlas/v2/resourceName/operations/subresource')).toBe(false); + }); + }); + + describe('isSingleOperationPath', () => { + it('recognizes single Operations resources', () => { + expect(isSingleOperationPath('/api/atlas/v2/groups/{groupId}/clusters/operations/{operationId}')).toBe(true); + expect( + isSingleOperationPath('/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations/{operationId}') + ).toBe(true); + expect(isSingleOperationPath('/api/atlas/v2/operations/{operationId}')).toBe(true); + }); + + it('rejects other paths', () => { + expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations')).toBe(false); + expect(isSingleOperationPath('/api/atlas/v2/resourceName/{pathParam}')).toBe(false); + expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(false); + }); + + it('is lenient about the path parameter syntax, casing is covered by IPA-102', () => { + expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations/{OperationId}')).toBe(true); + }); + }); + + describe('isOperationsPath', () => { + it('recognizes both Operations resource forms', () => { + expect(isOperationsPath('/api/atlas/v2/resourceName/operations')).toBe(true); + expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(true); + }); + + it('ignores custom method paths', () => { + expect(isOperationsPath('/api/atlas/v2/resourceName/operations:customMethod')).toBe(false); + expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}:cancel')).toBe(false); + expect(containsOperationsSegment('/api/atlas/v2/resourceName/operations/{operationId}:cancel')).toBe(false); + }); + + it('rejects paths nested below an Operations resource', () => { + expect(isOperationsPath('/api/atlas/v2/resourceName/operations/subresource')).toBe(false); + expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(false); + }); + }); + + describe('containsOperationsSegment', () => { + it('finds operations segments at any position', () => { + expect(containsOperationsSegment('/api/atlas/v2/resourceName/operations')).toBe(true); + expect(containsOperationsSegment('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(true); + expect(containsOperationsSegment('/api/atlas/v2/operations/subresource')).toBe(true); + }); + + it('rejects paths without an operations segment', () => { + expect(containsOperationsSegment('/api/atlas/v2/resourceName')).toBe(false); + expect(containsOperationsSegment('/api/atlas/v2/resourceName/{operationId}')).toBe(false); + }); + }); + + describe('operationsSegmentIsLeaf', () => { + it('accepts leaf Operations endpoints and paths without an operations segment', () => { + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations')).toBe(true); + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(true); + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName')).toBe(true); + }); + + it('rejects nesting below the first operations segment', () => { + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations/subresource')).toBe(false); + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(false); + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations/{operationId}/operations')).toBe(false); + expect(operationsSegmentIsLeaf('/api/atlas/v2/operations/subresource')).toBe(false); + }); + }); +}); diff --git a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js index b41b30f781..6dd83b664b 100644 --- a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js +++ b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js @@ -4,6 +4,7 @@ import { getResourcePathItems, isReadOnlyResource, isResourceCollectionIdentifier, + isRootLevelResource, isSingleResourceIdentifier, isSingletonResource, } from '../../rulesets/functions/utils/resourceEvaluation'; @@ -589,4 +590,22 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () }); }); }); + + describe('isRootLevelResource', () => { + it('recognizes root-level resources', () => { + expect(isRootLevelResource('/api/atlas/v2/resourceName')).toBe(true); + expect(isRootLevelResource('/api/atlas/v2/resourceName/{id}')).toBe(true); + expect(isRootLevelResource('/api/atlas/v2/operations')).toBe(true); + expect(isRootLevelResource('/api/atlas/v2/operations/{operationId}')).toBe(true); + expect(isRootLevelResource('/api/atlas/v2/unauth/resourceName')).toBe(true); + }); + + it('rejects resources with a parent', () => { + expect(isRootLevelResource('/api/atlas/v2/resourceName/childResource')).toBe(false); + expect(isRootLevelResource('/api/atlas/v2/resourceName/{id}/childResource')).toBe(false); + expect(isRootLevelResource('/api/atlas/v2/resourceName/childResource/{id}')).toBe(false); + expect(isRootLevelResource('/api/atlas/v2/resourceName/operations')).toBe(false); + expect(isRootLevelResource('/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}')).toBe(false); + }); + }); }); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index 68b72b3d1c..e15fff8b6f 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -19,6 +19,7 @@ extends: - ./rulesets/IPA-124.yaml - ./rulesets/IPA-125.yaml - ./rulesets/IPA-126.yaml + - ./rulesets/IPA-132.yaml overrides: - files: diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml new file mode 100644 index 0000000000..c8a3a422bf --- /dev/null +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -0,0 +1,74 @@ +# IPA-132: Long-Running Operations +# https://mongodb.github.io/ipa/132 + +functions: + - IPA132OperationsEndpointMustNotBeGlobal + - IPA132OperationMustBeAReadOnlyResource + - IPA132OperationsEndpointMustBeALeafResource + +rules: + xgen-IPA-132-operations-endpoint-must-not-be-global: + description: | + Operations endpoints must not be defined as standalone, global endpoints with no parent + resource in their path. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an + `operations/{operationId}` suffix + - The `operations` segment must be preceded by at least one parent resource segment + - A root-level Operations endpoint, such as `/api/atlas/v2/operations` or + `/api/atlas/v2/unauth/operations`, is a violation + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operations-endpoint-must-not-be-global' + severity: warn + given: $.paths + then: + field: '@key' + function: IPA132OperationsEndpointMustNotBeGlobal + + xgen-IPA-132-operation-must-be-a-read-only-resource: + description: | + Operations endpoints are read-only. They may only define the get method, and all properties + of the Operation resource must be readOnly. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an + `operations/{operationId}` suffix + - The path item must not define any HTTP method other than `get` + - On the single Operation endpoint (`.../operations/{operationId}`), where the Get method + is defined, all properties of every 2xx response schema of the `get` method must be + marked as `readOnly: true` + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operation-must-be-a-read-only-resource' + severity: warn + given: $.paths + then: + field: '@key' + function: IPA132OperationMustBeAReadOnlyResource + + xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource: + description: | + Operations endpoints must be leaf resources, with no resources nested below them. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to paths containing an `operations` segment + - An `operations` segment may only be followed by a single operation identifier path + parameter, e.g. `.../operations` and `.../operations/{operationId}` are valid + - Any further nesting, such as `.../operations/subresource` or + `.../operations/{operationId}/subresource`, is a violation + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource' + severity: warn + given: $.paths + then: + field: '@key' + function: IPA132OperationsEndpointMustBeALeafResource diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index a4e0fcf4f0..0b4dacbcb0 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -1406,4 +1406,58 @@ This rule includes two configuration options: +### IPA-132 + +Rules are based on [https://mongodb.github.io/ipa/132](https://mongodb.github.io/ipa/132). + +#### xgen-IPA-132-operations-endpoint-must-not-be-global + + ![warn](https://img.shields.io/badge/warning-yellow) +Operations endpoints must not be defined as standalone, global endpoints with no parent +resource in their path. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an + `operations/{operationId}` suffix + - The `operations` segment must be preceded by at least one parent resource segment + - A root-level Operations endpoint, such as `/api/atlas/v2/operations` or + `/api/atlas/v2/unauth/operations`, is a violation + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operation-must-be-a-read-only-resource + + ![warn](https://img.shields.io/badge/warning-yellow) +Operations endpoints are read-only. They may only define the get method, and all properties +of the Operation resource must be readOnly. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an + `operations/{operationId}` suffix + - The path item must not define any HTTP method other than `get` + - On the single Operation endpoint (`.../operations/{operationId}`), where the Get method + is defined, all properties of every 2xx response schema of the `get` method must be + marked as `readOnly: true` + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource + + ![warn](https://img.shields.io/badge/warning-yellow) +Operations endpoints must be leaf resources, with no resources nested below them. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to paths containing an `operations` segment + - An `operations` segment may only be followed by a single operation identifier path + parameter, e.g. `.../operations` and `.../operations/{operationId}` are valid + - Any further nesting, such as `.../operations/subresource` or + `.../operations/{operationId}/subresource`, is a violation + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + + + diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js new file mode 100644 index 0000000000..5a7d789a30 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js @@ -0,0 +1,70 @@ +import { allPropertiesAreReadOnly } from './utils/resourceEvaluation.js'; +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { isOperationsPath, isSingleOperationPath } from './utils/longRunningOperations.js'; + +const VALID_METHOD = 'get'; +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; +const READ_ONLY_SCHEMA_ERROR_MESSAGE = + 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.'; + +/** + * Checks that an Operations endpoint defined by IPA-132 is a read-only resource: its path items + * may only define the get method, and all properties of the Operation resource are readOnly. + * + * @param {string} input - The path key from the OpenAPI spec + * @param {object} _ - Unused + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.resolved; + + if (!isOperationsPath(input)) { + return; + } + + const pathItem = oas.paths[input]; + const errors = checkViolationsAndReturnErrors(input, pathItem, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, pathItem, path); +}; + +function checkViolationsAndReturnErrors(input, pathItem, path, ruleName) { + try { + const errors = []; + + // Extract the keys which are equivalent of the http methods + const httpMethods = Object.keys(pathItem).filter((key) => HTTP_METHODS.includes(key)); + for (const method of httpMethods) { + if (method !== VALID_METHOD) { + errors.push({ + path: [...path, method], + message: `Operations endpoints are read-only and do not allow the ${method} method.`, + }); + } + } + + // The List method on the collection reuses the Operation resource schema, so the readOnly + // condition is validated on the single Operation endpoint, where the Get method is defined + if (isSingleOperationPath(input) && !hasReadOnlyGetResponseSchema(pathItem)) { + errors.push({ path: [...path, 'get'], message: READ_ONLY_SCHEMA_ERROR_MESSAGE }); + } + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} + +function hasReadOnlyGetResponseSchema(pathItem) { + const responses = pathItem.get?.responses ?? {}; + for (const [responseCode, response] of Object.entries(responses)) { + if (!responseCode.startsWith('2')) { + continue; + } + for (const mediaTypeObject of Object.values(response?.content ?? {})) { + if (mediaTypeObject?.schema && !allPropertiesAreReadOnly(mediaTypeObject.schema)) { + return false; + } + } + } + return true; +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js new file mode 100644 index 0000000000..2ea892f185 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js @@ -0,0 +1,36 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { containsOperationsSegment, operationsSegmentIsLeaf } from './utils/longRunningOperations.js'; + +const ERROR_MESSAGE = + 'Operations endpoints must be leaf resources. An `operations` segment may only be followed by a single operation identifier path parameter.'; + +/** + * Checks that an Operations resource defined by IPA-132 is a leaf resource, i.e. nothing is nested + * below the first `operations` segment other than a single `{operationId}` path parameter. + * + * @param {string} input - The path key from the OpenAPI spec + * @param {object} _ - Unused + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.resolved; + + if (!containsOperationsSegment(input)) { + return; + } + + const errors = checkViolationsAndReturnErrors(input, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input], path); +}; + +function checkViolationsAndReturnErrors(input, path, ruleName) { + try { + if (!operationsSegmentIsLeaf(input)) { + return [{ path, message: ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js new file mode 100644 index 0000000000..205600cb1b --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js @@ -0,0 +1,37 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { isRootLevelResource } from './utils/resourceEvaluation.js'; +import { isOperationsPath } from './utils/longRunningOperations.js'; + +const ERROR_MESSAGE = + 'Operations endpoints must not be standalone, global endpoints with no parent resource in their path.'; + +/** + * Checks that an Operations endpoint defined by IPA-132 is not a standalone, global endpoint, + * rejecting Operations endpoints mounted at the API root such as `/api/atlas/v2/operations`. + * + * @param {string} input - The path key from the OpenAPI spec + * @param {object} _ - Unused + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.resolved; + + if (!isOperationsPath(input)) { + return; + } + + const errors = checkViolationsAndReturnErrors(input, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input], path); +}; + +function checkViolationsAndReturnErrors(input, path, ruleName) { + try { + if (isRootLevelResource(input)) { + return [{ path, message: ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js new file mode 100644 index 0000000000..7e82cc02b6 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js @@ -0,0 +1,102 @@ +import { isCustomMethodIdentifier, isPathParam, removePrefix } from './resourceEvaluation.js'; + +export const OPERATIONS_SEGMENT = 'operations'; + +/** + * Splits a path into its resource identifier segments, ignoring the standard path prefix, so that + * the segments describe the resource hierarchy only. + * + * Custom method paths (`:customMethod`) yield no segments, keeping them out of scope for the + * IPA-132 Operations rules: flagging their methods here would contradict the IPA-109 requirement + * that custom methods use POST or GET. + * + * @param {string} path the path to split + * @returns {string[]} the resource identifier segments + */ +function toResourceSegments(path) { + if (isCustomMethodIdentifier(path)) { + return []; + } + return removePrefix(path) + .split('/') + .filter((segment) => segment.length > 0); +} + +/** + * Checks if a path identifies an Operations resource collection defined by IPA-132, i.e. the path + * ends with an `operations` segment. For example: + * '/api/atlas/v2/resourceName/operations' returns true + * '/api/atlas/v2/resourceName/{pathParam}/operations' returns true + * '/api/atlas/v2/operations' returns true + * '/api/atlas/v2/resourceName/operations/{operationId}' returns false + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path identifies an Operations resource collection + */ +export function isOperationsCollectionPath(path) { + const segments = toResourceSegments(path); + return segments.length > 0 && segments[segments.length - 1] === OPERATIONS_SEGMENT; +} + +/** + * Checks if a path identifies a single Operations resource defined by IPA-132, i.e. the path ends + * with an `operations` segment followed by a single path parameter. For example: + * '/api/atlas/v2/resourceName/operations/{operationId}' returns true + * '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}' returns true + * '/api/atlas/v2/resourceName/operations' returns false + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path identifies a single Operations resource + */ +export function isSingleOperationPath(path) { + const segments = toResourceSegments(path); + return ( + segments.length > 1 && + segments[segments.length - 2] === OPERATIONS_SEGMENT && + isPathParam(segments[segments.length - 1]) + ); +} + +/** + * Checks if a path identifies an Operations resource defined by IPA-132, either the Operations + * resource collection or a single Operations resource. + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path identifies an Operations resource + */ +export function isOperationsPath(path) { + return isOperationsCollectionPath(path) || isSingleOperationPath(path); +} + +/** + * Checks if any resource identifier segment of a path is an `operations` segment, regardless of + * its position in the path. + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path contains an `operations` segment + */ +export function containsOperationsSegment(path) { + return toResourceSegments(path).includes(OPERATIONS_SEGMENT); +} + +/** + * Checks that nothing is nested below the first `operations` segment of a path: the segment may + * only be followed by a single operation identifier path parameter. Paths without an `operations` + * segment are considered leaves. For example: + * '/api/atlas/v2/resourceName/operations' returns true + * '/api/atlas/v2/resourceName/operations/{operationId}' returns true + * '/api/atlas/v2/resourceName/operations/subresource' returns false + * '/api/atlas/v2/resourceName/operations/{operationId}/operations' returns false + * + * @param {string} path the path to evaluate + * @returns {boolean} true if nothing is nested below the first `operations` segment + */ +export function operationsSegmentIsLeaf(path) { + const segments = toResourceSegments(path); + const firstIndex = segments.indexOf(OPERATIONS_SEGMENT); + if (firstIndex === -1) { + return true; + } + const trailingSegments = segments.slice(firstIndex + 1); + return trailingSegments.length === 0 || (trailingSegments.length === 1 && isPathParam(trailingSegments[0])); +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js index a3749fbef7..2b811bfa46 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js +++ b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js @@ -51,6 +51,27 @@ export function isSingleResourceIdentifier(path) { return isResourceCollectionIdentifier(collectionPath); } +/** + * Checks whether a resource is root-level, i.e. mounted directly under the API prefix with no + * parent resource. Both the resource collection path and the single resource path are recognized. + * For example: + * '/resource' returns true + * '/resource/{id}' returns true + * '/parent/{id}/resource' returns false + * '/parent/resource/{id}' returns false + * + * @param {string} resourcePath a path for a resource + * @returns {boolean} + */ +export function isRootLevelResource(resourcePath) { + const path = removePrefix(resourcePath); + const sections = path.split('/').filter((section) => section.length > 0); + if (sections.length > 0 && isPathParam(sections[sections.length - 1])) { + sections.pop(); + } + return sections.length === 1; +} + export function isCustomMethodIdentifier(path) { return path.includes(':'); }