diff --git a/tools/spectral/ipa/__tests__/IPA132OperationEndpointsMustReturnOperationResponse.test.js b/tools/spectral/ipa/__tests__/IPA132OperationEndpointsMustReturnOperationResponse.test.js new file mode 100644 index 0000000000..378a54b3f8 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationEndpointsMustReturnOperationResponse.test.js @@ -0,0 +1,548 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const SINGLE_OPERATION_ERROR_MESSAGE = 'The Operation endpoint must return the OperationResponse schema.'; +const OPERATIONS_COLLECTION_ERROR_MESSAGE = + 'The Operations collection endpoint must return a paginated response whose results reference the OperationResponse schema.'; +const MISSING_SCHEMA_ERROR_MESSAGE = 'The Operations endpoint must define a JSON response schema.'; + +const components = { + schemas: { + OperationResponse: { + type: 'object', + properties: { + operationId: { type: 'string' }, + }, + }, + PaginatedOperationResponse: { + type: 'object', + properties: { + results: { + type: 'array', + items: { $ref: '#/components/schemas/OperationResponse' }, + }, + totalCount: { type: 'integer' }, + }, + }, + Order: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + PaginatedOrderResponse: { + type: 'object', + properties: { + results: { + type: 'array', + items: { $ref: '#/components/schemas/Order' }, + }, + }, + }, + PaginatedMetadata: { + type: 'object', + properties: { + totalCount: { type: 'integer' }, + }, + }, + // A wrapper composed with allOf is rejected: the IPA-110 pagination rules require the + // results array to be defined directly on the wrapper schema + PaginatedAllOfOperationResponse: { + allOf: [ + { $ref: '#/components/schemas/PaginatedMetadata' }, + { + type: 'object', + properties: { + results: { + type: 'array', + items: { $ref: '#/components/schemas/OperationResponse' }, + }, + }, + }, + ], + }, + ApiError: { + type: 'object', + properties: { + error: { type: 'string' }, + }, + }, + }, +}; + +const operationResponseGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { $ref: '#/components/schemas/OperationResponse' }, + }, + }, + }, + }, +}; + +const operationResponseGetWithNotFound = { + responses: { + ...operationResponseGet.responses, + 404: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { $ref: '#/components/schemas/ApiError' }, + }, + }, + }, + }, +}; + +const paginatedOperationResponseGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { $ref: '#/components/schemas/PaginatedOperationResponse' }, + }, + }, + }, + }, +}; + +const paginatedAllOfOperationResponseGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { $ref: '#/components/schemas/PaginatedAllOfOperationResponse' }, + }, + }, + }, + }, +}; + +// Inline paginated wrappers are rejected, consistent with xgen-IPA-110-collections-use-paginated-prefix +const inlinePaginatedOperationResponseGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + type: 'object', + properties: { + results: { + type: 'array', + items: { $ref: '#/components/schemas/OperationResponse' }, + }, + }, + }, + }, + }, + }, + }, +}; + +const operationResponseArrayGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/OperationResponse' }, + }, + }, + }, + }, + }, +}; + +const orderGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { $ref: '#/components/schemas/Order' }, + }, + }, + }, + }, +}; + +const paginatedOrderResponseGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { $ref: '#/components/schemas/PaginatedOrderResponse' }, + }, + }, + }, + }, +}; + +const inlineSchemaGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + type: 'object', + properties: { + operationId: { type: 'string' }, + }, + }, + }, + }, + }, + }, +}; + +testRule('xgen-IPA-132-operation-endpoints-must-return-operation-response', [ + { + name: 'valid Operations endpoints returning OperationResponse', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: paginatedOperationResponseGet, + }, + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: operationResponseGetWithNotFound, + }, + '/api/atlas/v2/resourceName/{pathParam}/operations': { + get: paginatedOperationResponseGet, + }, + // Not an Operations endpoint, the returned schema is not restricted + '/api/atlas/v2/resourceName/{pathParam}': { + get: orderGet, + }, + }, + components, + }, + errors: [], + }, + { + name: 'invalid single Operation endpoint returning the parent resource', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: orderGet, + }, + }, + components, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: SINGLE_OPERATION_ERROR_MESSAGE, + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations/{operationId}', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid single Operation endpoint with an inline schema', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: inlineSchemaGet, + }, + }, + components, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: SINGLE_OPERATION_ERROR_MESSAGE, + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations/{operationId}', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid Operations endpoint without a JSON response schema', + document: { + paths: { + // No content at all + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: { + responses: { + 200: { description: 'Operation status.' }, + }, + }, + }, + // A JSON media type without a schema + '/api/atlas/v2/resourceName/operations': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': {}, + }, + }, + }, + }, + }, + // Only non-JSON media types + '/api/atlas/v2/resourceName/{pathParam}/operations': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+gzip': { + schema: { type: 'string', format: 'binary' }, + }, + }, + }, + }, + }, + }, + }, + components, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: MISSING_SCHEMA_ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'get', 'responses', '200'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: MISSING_SCHEMA_ERROR_MESSAGE, + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: MISSING_SCHEMA_ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/{pathParam}/operations', 'get', 'responses', '200'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid Operations collection returning a non-Operation paginated response', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: paginatedOrderResponseGet, + }, + }, + components, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: OPERATIONS_COLLECTION_ERROR_MESSAGE, + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid single Operation endpoint returning an array of OperationResponse', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: operationResponseArrayGet, + }, + }, + components, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: SINGLE_OPERATION_ERROR_MESSAGE, + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations/{operationId}', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid Operations collection with an inline paginated wrapper', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: inlinePaginatedOperationResponseGet, + }, + }, + components, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: OPERATIONS_COLLECTION_ERROR_MESSAGE, + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid Operations collection with an allOf-composed paginated wrapper', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: paginatedAllOfOperationResponseGet, + }, + }, + components, + }, + errors: [ + // The IPA-110 pagination rules require the results array directly on the wrapper schema, + // so an allOf-composed wrapper would fail xgen-IPA-110-collections-response-define-results-array + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: OPERATIONS_COLLECTION_ERROR_MESSAGE, + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid Operations collection returning an unpaginated OperationResponse', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: operationResponseGet, + }, + }, + components, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: OPERATIONS_COLLECTION_ERROR_MESSAGE, + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid responses with exceptions', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { $ref: '#/components/schemas/Order' }, + }, + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-endpoints-must-return-operation-response': 'reason', + }, + }, + }, + }, + }, + }, + components, + }, + errors: [], + }, + { + name: 'compliant responses do not need an exception', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { $ref: '#/components/schemas/OperationResponse' }, + }, + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-endpoints-must-return-operation-response': 'reason', + }, + }, + }, + }, + }, + }, + components, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations/{operationId}', + 'get', + 'responses', + '200', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operation-endpoints-must-return-operation-response', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineNestedObjectStructure.test.js b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineNestedObjectStructure.test.js new file mode 100644 index 0000000000..ff07fbbc75 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineNestedObjectStructure.test.js @@ -0,0 +1,204 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const validOperationResponse = { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + retryable: { type: 'boolean' }, + retryStrategy: { type: 'string', enum: ['IMMEDIATE', 'BACKOFF', 'NONE'] }, + details: { type: 'object' }, + }, + }, + }, +}; + +testRule('xgen-IPA-132-operation-response-must-define-nested-object-structure', [ + { + name: 'valid OperationResponse with a structured error object', + document: { + components: { + schemas: { + OperationResponse: validOperationResponse, + }, + }, + }, + errors: [], + }, + { + name: 'valid OperationResponse with a referenced error object', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + error: { $ref: '#/components/schemas/OperationError' }, + }, + }, + OperationError: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + retryable: { type: 'boolean' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'nested objects that are not defined are skipped', + document: { + components: { + schemas: { + // A missing error field is the optional-fields rule's concern + OperationResponse: { + type: 'object', + properties: { + operationId: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'composed schemas are skipped', + document: { + components: { + schemas: { + // Composition is rejected by the must-not-use-composition rule, so this rule stays silent + OperationResponse: { + allOf: [{ type: 'object', properties: { statusMessage: { type: 'string' } } }], + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid error object missing structured properties', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + code: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-define-nested-object-structure', + message: 'The error object must define a message property.', + path: ['components', 'schemas', 'OperationResponse', 'properties', 'error'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-nested-object-structure', + message: 'The error object must define a retryable property.', + path: ['components', 'schemas', 'OperationResponse', 'properties', 'error'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid error modeled as a plain string', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + error: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-define-nested-object-structure', + message: 'The error object must define a code property.', + path: ['components', 'schemas', 'OperationResponse', 'properties', 'error'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-nested-object-structure', + message: 'The error object must define a message property.', + path: ['components', 'schemas', 'OperationResponse', 'properties', 'error'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-nested-object-structure', + message: 'The error object must define a retryable property.', + path: ['components', 'schemas', 'OperationResponse', 'properties', 'error'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid OperationResponse with an exception', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + error: { type: 'string' }, + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-define-nested-object-structure': 'reason', + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'compliant OperationResponse does not need an exception', + document: { + components: { + schemas: { + OperationResponse: { + ...validOperationResponse, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-define-nested-object-structure': 'reason', + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-define-nested-object-structure', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'components', + 'schemas', + 'OperationResponse', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operation-response-must-define-nested-object-structure', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineOptionalFields.test.js b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineOptionalFields.test.js new file mode 100644 index 0000000000..ae313a5ac0 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineOptionalFields.test.js @@ -0,0 +1,173 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const CUSTOM_METHOD_REQUIRED_ERROR_MESSAGE = + 'The customMethod property must not be listed as required. It is present only when operationType is CUSTOM, which cannot be validated statically.'; +const ERROR_REQUIRED_ERROR_MESSAGE = + 'The error property must not be listed as required. It is present only when the operation has FAILED, which cannot be validated statically.'; + +const validOperationResponse = { + type: 'object', + properties: { + customMethod: { type: 'string' }, + error: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + retryable: { type: 'boolean' }, + }, + }, + resultHref: { type: 'string', format: 'uri' }, + retryAfterSeconds: { type: 'integer' }, + }, +}; + +testRule('xgen-IPA-132-operation-response-must-define-optional-fields', [ + { + name: 'valid OperationResponse with all conditional fields', + document: { + components: { + schemas: { + OperationResponse: validOperationResponse, + }, + }, + }, + errors: [], + }, + { + name: 'documents without an OperationResponse schema are ignored', + document: { + components: { + schemas: { + Order: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'composed schemas are skipped', + document: { + components: { + schemas: { + // Composition is rejected by the must-not-use-composition rule, so this rule stays silent + OperationResponse: { + allOf: [{ type: 'object', properties: { statusMessage: { type: 'string' } } }], + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid OperationResponse missing conditional fields', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + customMethod: { type: 'string' }, + resultHref: { type: 'string', format: 'uri' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-define-optional-fields', + message: 'OperationResponse must define the error property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-optional-fields', + message: 'OperationResponse must define the retryAfterSeconds property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid conditional fields listed as required', + document: { + components: { + schemas: { + OperationResponse: { + ...validOperationResponse, + required: ['customMethod', 'error'], + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-define-optional-fields', + message: CUSTOM_METHOD_REQUIRED_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse', 'properties', 'customMethod'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-optional-fields', + message: ERROR_REQUIRED_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse', 'properties', 'error'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid OperationResponse with an exception', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + customMethod: { type: 'string' }, + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-define-optional-fields': 'reason', + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'compliant OperationResponse does not need an exception', + document: { + components: { + schemas: { + OperationResponse: { + ...validOperationResponse, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-define-optional-fields': 'reason', + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-define-optional-fields', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'components', + 'schemas', + 'OperationResponse', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operation-response-must-define-optional-fields', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineRequiredFields.test.js b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineRequiredFields.test.js new file mode 100644 index 0000000000..b24dddf489 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustDefineRequiredFields.test.js @@ -0,0 +1,158 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const validOperationResponse = { + type: 'object', + required: ['operationId', 'status', 'operationType', 'createdAt', 'updatedAt', 'expiresAt'], + properties: { + operationId: { type: 'string' }, + status: { type: 'string', enum: ['PENDING', 'IN_PROGRESS', 'SUCCEEDED', 'FAILED', 'CANCELED', 'SUPERSEDED'] }, + operationType: { type: 'string', enum: ['CREATE', 'UPDATE', 'DELETE', 'CUSTOM'] }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: 'string', format: 'date-time' }, + }, +}; + +testRule('xgen-IPA-132-operation-response-must-define-required-fields', [ + { + name: 'valid OperationResponse with all required fields', + document: { + components: { + schemas: { + OperationResponse: validOperationResponse, + }, + }, + }, + errors: [], + }, + { + name: 'documents without an OperationResponse schema are ignored', + document: { + components: { + schemas: { + Order: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid OperationResponse missing required fields', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + required: ['operationType'], + properties: { + operationType: { type: 'string', enum: ['CREATE', 'UPDATE', 'DELETE', 'CUSTOM'] }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + errors: [ + // Results are ordered by document position: the schema-level errors anchor at the schema + // object, before the property-level errors + { + code: 'xgen-IPA-132-operation-response-must-define-required-fields', + message: 'OperationResponse must define the operationId property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-required-fields', + message: 'OperationResponse must define the status property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-required-fields', + message: 'OperationResponse must define the updatedAt property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-required-fields', + message: 'OperationResponse must define the expiresAt property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-define-required-fields', + message: 'The createdAt property must be listed as required.', + path: ['components', 'schemas', 'OperationResponse', 'properties', 'createdAt'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'composed schemas are skipped', + document: { + components: { + schemas: { + // Composition is rejected by the must-not-use-composition rule, so this rule stays silent + OperationResponse: { + allOf: [{ type: 'object', properties: { statusMessage: { type: 'string' } } }], + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid OperationResponse with an exception', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + operationType: { type: 'string', enum: ['CREATE', 'UPDATE', 'DELETE', 'CUSTOM'] }, + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-define-required-fields': 'reason', + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'compliant OperationResponse does not need an exception', + document: { + components: { + schemas: { + OperationResponse: { + ...validOperationResponse, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-define-required-fields': 'reason', + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-define-required-fields', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'components', + 'schemas', + 'OperationResponse', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operation-response-must-define-required-fields', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationResponseMustNotUseComposition.test.js b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustNotUseComposition.test.js new file mode 100644 index 0000000000..364b22682a --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustNotUseComposition.test.js @@ -0,0 +1,178 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const COMPOSITION_ERROR_MESSAGE = + 'OperationResponse must define its properties directly, without allOf, oneOf or anyOf composition.'; + +const flatOperationResponse = { + type: 'object', + properties: { + operationId: { type: 'string' }, + status: { type: 'string', enum: ['PENDING', 'IN_PROGRESS', 'SUCCEEDED', 'FAILED', 'CANCELED', 'SUPERSEDED'] }, + }, +}; + +testRule('xgen-IPA-132-operation-response-must-not-use-composition', [ + { + name: 'valid flat OperationResponse', + document: { + components: { + schemas: { + OperationResponse: flatOperationResponse, + }, + }, + }, + errors: [], + }, + { + name: 'documents without an OperationResponse schema are ignored', + document: { + components: { + schemas: { + Order: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid OperationResponse composed with allOf', + document: { + components: { + schemas: { + // There is one and only one OperationResponse: composition is not allowed + OperationResponse: { + allOf: [{ $ref: '#/components/schemas/OperationMetadata' }, flatOperationResponse], + }, + OperationMetadata: { + type: 'object', + properties: { + statusMessage: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-not-use-composition', + message: COMPOSITION_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid OperationResponse composed with oneOf', + document: { + components: { + schemas: { + OperationResponse: { + oneOf: [flatOperationResponse], + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-not-use-composition', + message: COMPOSITION_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid OperationResponse composed with anyOf', + document: { + components: { + schemas: { + OperationResponse: { + anyOf: [flatOperationResponse], + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-not-use-composition', + message: COMPOSITION_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'an exception for another rule does not suppress the composition violation', + document: { + components: { + schemas: { + OperationResponse: { + allOf: [flatOperationResponse], + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-define-required-fields': 'we compose from a base schema', + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-not-use-composition', + message: COMPOSITION_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid OperationResponse with an exception', + document: { + components: { + schemas: { + OperationResponse: { + allOf: [flatOperationResponse], + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-not-use-composition': 'reason', + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'compliant OperationResponse does not need an exception', + document: { + components: { + schemas: { + OperationResponse: { + ...flatOperationResponse, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-not-use-composition': 'reason', + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-not-use-composition', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'components', + 'schemas', + 'OperationResponse', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operation-response-must-not-use-composition', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationResponseMustUseStandardEnumValues.test.js b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustUseStandardEnumValues.test.js new file mode 100644 index 0000000000..335b91528d --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationResponseMustUseStandardEnumValues.test.js @@ -0,0 +1,177 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const STATUS_ENUM_ERROR_MESSAGE = + 'The status field must use exactly the enum [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED].'; +const OPERATION_TYPE_ENUM_ERROR_MESSAGE = + 'The operationType field must use exactly the enum [CREATE, UPDATE, DELETE, CUSTOM].'; +const RETRY_STRATEGY_ENUM_ERROR_MESSAGE = + 'The retryStrategy field must use exactly the enum [IMMEDIATE, BACKOFF, NONE].'; + +const validOperationResponse = { + type: 'object', + properties: { + status: { type: 'string', enum: ['PENDING', 'IN_PROGRESS', 'SUCCEEDED', 'FAILED', 'CANCELED', 'SUPERSEDED'] }, + operationType: { type: 'string', enum: ['CREATE', 'UPDATE', 'DELETE', 'CUSTOM'] }, + error: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + retryable: { type: 'boolean' }, + retryStrategy: { type: 'string', enum: ['IMMEDIATE', 'BACKOFF', 'NONE'] }, + }, + }, + }, +}; + +testRule('xgen-IPA-132-operation-response-must-use-standard-enum-values', [ + { + name: 'valid OperationResponse with all standard enums', + document: { + components: { + schemas: { + OperationResponse: validOperationResponse, + }, + }, + }, + errors: [], + }, + { + name: 'enum fields that are not defined are skipped', + document: { + components: { + schemas: { + // Missing fields are the required-fields and optional-fields rules' concern + OperationResponse: { + type: 'object', + properties: { + operationId: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'composed schemas are skipped', + document: { + components: { + schemas: { + // Composition is rejected by the must-not-use-composition rule, so this rule stays silent + OperationResponse: { + allOf: [{ type: 'object', properties: { statusMessage: { type: 'string' } } }], + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid enums with missing and renamed values', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + status: { type: 'string', enum: ['QUEUED', 'RUNNING', 'COMPLETE', 'ERROR', 'CANCELLED'] }, + operationType: { type: 'string', enum: ['CREATE', 'UPDATE', 'DELETE'] }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-use-standard-enum-values', + message: STATUS_ENUM_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse', 'properties', 'status'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-response-must-use-standard-enum-values', + message: OPERATION_TYPE_ENUM_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse', 'properties', 'operationType'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid retryStrategy enum nested in the error object', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + retryStrategy: { type: 'string', enum: ['IMMEDIATE', 'EXPONENTIAL'] }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-use-standard-enum-values', + message: RETRY_STRATEGY_ENUM_ERROR_MESSAGE, + path: ['components', 'schemas', 'OperationResponse', 'properties', 'error'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid OperationResponse with an exception', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + status: { type: 'string', enum: ['QUEUED'] }, + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-use-standard-enum-values': 'reason', + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'compliant OperationResponse does not need an exception', + document: { + components: { + schemas: { + OperationResponse: { + ...validOperationResponse, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-response-must-use-standard-enum-values': 'reason', + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-response-must-use-standard-enum-values', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'components', + 'schemas', + 'OperationResponse', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operation-response-must-use-standard-enum-values', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsShouldExposeStatusFields.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsShouldExposeStatusFields.test.js new file mode 100644 index 0000000000..d3139cd79c --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationsShouldExposeStatusFields.test.js @@ -0,0 +1,177 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const progress = { + type: 'object', + properties: { + completed: { type: 'number' }, + total: { type: 'number' }, + unit: { type: 'string' }, + }, +}; + +const operationResponseWithStatusFields = { + type: 'object', + properties: { + statusMessage: { type: 'string' }, + progress, + estimatedCompletionTime: { type: 'string', format: 'date-time' }, + }, +}; + +testRule('xgen-IPA-132-operations-should-expose-status-fields', [ + { + name: 'valid OperationResponse with all status fields', + document: { + components: { + schemas: { + OperationResponse: operationResponseWithStatusFields, + }, + }, + }, + errors: [], + }, + { + name: 'documents without an OperationResponse schema are ignored', + document: { + components: { + schemas: { + Order: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'composed schemas are skipped', + document: { + components: { + schemas: { + // Composition is rejected by the must-not-use-composition rule, so this rule stays silent + OperationResponse: { + allOf: [{ type: 'object', properties: { statusMessage: { type: 'string' } } }], + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid OperationResponse without any status fields', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + status: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-should-expose-status-fields', + message: 'OperationResponse should define the statusMessage property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operations-should-expose-status-fields', + message: 'OperationResponse should define the progress property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operations-should-expose-status-fields', + message: 'OperationResponse should define the estimatedCompletionTime property.', + path: ['components', 'schemas', 'OperationResponse'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid progress object missing a field', + document: { + components: { + schemas: { + OperationResponse: { + ...operationResponseWithStatusFields, + properties: { + ...operationResponseWithStatusFields.properties, + progress: { + type: 'object', + properties: { + completed: { type: 'number' }, + total: { type: 'number' }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-should-expose-status-fields', + message: 'The progress object should define the unit property.', + path: ['components', 'schemas', 'OperationResponse', 'properties', 'progress'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid OperationResponse with an exception', + document: { + components: { + schemas: { + OperationResponse: { + type: 'object', + properties: { + status: { type: 'string' }, + }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-should-expose-status-fields': 'reason', + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'compliant OperationResponse does not need an exception', + document: { + components: { + schemas: { + OperationResponse: { + ...operationResponseWithStatusFields, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-should-expose-status-fields': 'reason', + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-should-expose-status-fields', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'components', + 'schemas', + 'OperationResponse', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operations-should-expose-status-fields', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js index 461e303c0b..d64ab03e1a 100644 --- a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js +++ b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js @@ -1,10 +1,12 @@ import { describe, expect, it } from '@jest/globals'; import { containsOperationsSegment, + isExactEnumMatch, isOperationsCollectionPath, isOperationsPath, isSingleOperationPath, operationsSegmentIsLeaf, + usesComposition, } from '../../rulesets/functions/utils/longRunningOperations'; describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { @@ -88,4 +90,31 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(operationsSegmentIsLeaf('/api/atlas/v2/operations/subresource')).toBe(false); }); }); + + describe('isExactEnumMatch', () => { + it('accepts exact matches in any order', () => { + expect(isExactEnumMatch(['SUCCEEDED', 'PENDING', 'FAILED'], ['PENDING', 'SUCCEEDED', 'FAILED'])).toBe(true); + expect(isExactEnumMatch(['CUSTOM', 'DELETE', 'UPDATE', 'CREATE'], ['CREATE', 'UPDATE', 'DELETE', 'CUSTOM'])).toBe( + true + ); + }); + + it('rejects missing, extra, duplicate or undefined values', () => { + expect(isExactEnumMatch(['PENDING', 'SUCCEEDED'], ['PENDING', 'SUCCEEDED', 'FAILED'])).toBe(false); + expect(isExactEnumMatch(['PENDING', 'SUCCEEDED', 'FAILED', 'CANCELED'], ['PENDING', 'SUCCEEDED', 'FAILED'])).toBe( + false + ); + expect(isExactEnumMatch(['CREATE', 'CREATE', 'UPDATE'], ['CREATE', 'UPDATE', 'DELETE'])).toBe(false); + expect(isExactEnumMatch(undefined, ['CREATE', 'UPDATE'])).toBe(false); + }); + }); + + describe('usesComposition', () => { + it('detects allOf, oneOf and anyOf composition', () => { + expect(usesComposition({ allOf: [{ type: 'object' }] })).toBe(true); + expect(usesComposition({ oneOf: [{ type: 'object' }] })).toBe(true); + expect(usesComposition({ anyOf: [{ type: 'object' }] })).toBe(true); + expect(usesComposition({ type: 'object', properties: { id: { type: 'string' } } })).toBe(false); + }); + }); }); diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml index c8a3a422bf..a2b4c13503 100644 --- a/tools/spectral/ipa/rulesets/IPA-132.yaml +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -5,6 +5,17 @@ functions: - IPA132OperationsEndpointMustNotBeGlobal - IPA132OperationMustBeAReadOnlyResource - IPA132OperationsEndpointMustBeALeafResource + - IPA132OperationEndpointsMustReturnOperationResponse + - IPA132OperationResponseMustNotUseComposition + - IPA132OperationResponseMustDefineRequiredFields + - IPA132OperationResponseMustDefineOptionalFields + - IPA132OperationResponseMustUseStandardEnumValues + - IPA132OperationResponseMustDefineNestedObjectStructure + - IPA132OperationsShouldExposeStatusFields + +aliases: + OperationResponseSchema: + - '$.components.schemas.OperationResponse' rules: xgen-IPA-132-operations-endpoint-must-not-be-global: @@ -72,3 +83,203 @@ rules: then: field: '@key' function: IPA132OperationsEndpointMustBeALeafResource + + xgen-IPA-132-operation-endpoints-must-return-operation-response: + description: | + Operation endpoints must return the OperationResponse schema to report long-running + operation status. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to 2xx responses of GET methods on Operations endpoints; the response content + is defined inline, `components.responses` is reserved for error responses + - Every 2xx response must define at least one JSON media type carrying a schema + - The single Operation endpoint (`.../operations/{operationId}`) must reference the + `OperationResponse` schema + - The Operations collection endpoint (`.../operations`) must reference a paginated wrapper + schema that defines the `results` array directly; inline and `allOf`-composed wrappers + are rejected, consistent with `xgen-IPA-110-collections-use-paginated-prefix` and + `xgen-IPA-110-collections-response-define-results-array` + - Inline Operation schemas are violations, the response must reference the predefined + `OperationResponse` schema + - Responses with `x-xgen-IPA-exception` for this rule at the response level are excluded + from validation + + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operation-endpoints-must-return-operation-response' + severity: warn + given: $.paths[*].get.responses + then: + field: '@key' + function: IPA132OperationEndpointsMustReturnOperationResponse + + xgen-IPA-132-operation-response-must-not-use-composition: + description: | + The OperationResponse schema must define its properties directly. There is one and only one + OperationResponse, so allOf, oneOf and anyOf composition is a violation. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - The schema must not use allOf, oneOf or anyOf composition + - The other OperationResponse schema rules skip composed schemas, relying on this rule to + reject them + - Schemas 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-response-must-not-use-composition' + severity: warn + given: '#OperationResponseSchema' + then: + function: IPA132OperationResponseMustNotUseComposition + + xgen-IPA-132-operation-response-must-define-required-fields: + description: | + The OperationResponse schema must define every field the IPA-132 standard marks as always + required: operationId, status, operationType, createdAt, updatedAt and expiresAt. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - Each field listed in the rule's functionOptions must be defined and listed as required + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - For expiresAt, only the field declaration is validated; the retention behavior and the + 404 after expiry are backend-defined + - Schemas 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-response-must-define-required-fields' + severity: warn + given: '#OperationResponseSchema' + then: + function: IPA132OperationResponseMustDefineRequiredFields + functionOptions: + fields: + - operationId + - status + - operationType + - createdAt + - updatedAt + - expiresAt + + xgen-IPA-132-operation-response-must-define-optional-fields: + description: | + The OperationResponse schema must define every field the IPA-132 standard marks as + conditionally present, and none of them may be listed as required: customMethod, error, + resultHref and retryAfterSeconds are present only under runtime conditions. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - Each field listed in the rule's functionOptions must be defined and must not be listed + as required: the condition under which it is present is a runtime state that cannot be + validated statically + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - Schemas 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-response-must-define-optional-fields' + severity: warn + given: '#OperationResponseSchema' + then: + function: IPA132OperationResponseMustDefineOptionalFields + functionOptions: + fields: + - name: customMethod + condition: when operationType is CUSTOM + - name: error + condition: when the operation has FAILED + - name: resultHref + condition: when the operation has SUCCEEDED + - name: retryAfterSeconds + condition: while the operation is in a non-terminal state + + xgen-IPA-132-operation-response-must-use-standard-enum-values: + description: | + Every enum field of the OperationResponse schema must use exactly the standard enum values + defined by IPA-132: the status lifecycle enum, the operationType enum and the retryStrategy + enum of the error object. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - Each enum field listed in the rule's functionOptions must use exactly the listed values, + in any order + - Fields that are not defined are skipped: top-level field presence is validated by the + required-fields and optional-fields rules + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - Schemas 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-response-must-use-standard-enum-values' + severity: warn + given: '#OperationResponseSchema' + then: + function: IPA132OperationResponseMustUseStandardEnumValues + functionOptions: + enums: + - field: status + values: [PENDING, IN_PROGRESS, SUCCEEDED, FAILED, CANCELED, SUPERSEDED] + - field: operationType + values: [CREATE, UPDATE, DELETE, CUSTOM] + - field: retryStrategy + parent: error + values: [IMMEDIATE, BACKOFF, NONE] + + xgen-IPA-132-operation-response-must-define-nested-object-structure: + description: | + Every nested object field of the OperationResponse schema must define the properties the + IPA-132 standard requires of it: the error object must define code, message and retryable. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - Each nested object field listed in the rule's functionOptions must define the listed + properties + - Fields that are not defined are skipped: their presence is validated by the + optional-fields rule + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - Schemas 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-response-must-define-nested-object-structure' + severity: warn + given: '#OperationResponseSchema' + then: + function: IPA132OperationResponseMustDefineNestedObjectStructure + functionOptions: + objects: + - field: error + properties: [code, message, retryable] + + xgen-IPA-132-operations-should-expose-status-fields: + description: | + An Operation should expose statusMessage, progress, and estimatedCompletionTime so clients + can reason about in-flight work. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - The schema should define the fields listed in the rule's functionOptions: + `statusMessage`, `progress` and `estimatedCompletionTime` + - The progress object should define `completed`, `total` and `unit` properties + - Kept separate from the must-level rules because it enforces should-level guidance + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - Schemas 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-should-expose-status-fields' + severity: warn + given: '#OperationResponseSchema' + then: + function: IPA132OperationsShouldExposeStatusFields + functionOptions: + fields: [statusMessage, progress, estimatedCompletionTime] + objects: + - field: progress + properties: [completed, total, unit] diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 0b4dacbcb0..7657503932 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -1458,6 +1458,134 @@ Rule checks for the following conditions: `.../operations/{operationId}/subresource`, is a violation - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation +#### xgen-IPA-132-operation-endpoints-must-return-operation-response + + ![warn](https://img.shields.io/badge/warning-yellow) +Operation endpoints must return the OperationResponse schema to report long-running +operation status. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to 2xx responses of GET methods on Operations endpoints; the response content + is defined inline, `components.responses` is reserved for error responses + - Every 2xx response must define at least one JSON media type carrying a schema + - The single Operation endpoint (`.../operations/{operationId}`) must reference the + `OperationResponse` schema + - The Operations collection endpoint (`.../operations`) must reference a paginated wrapper + schema that defines the `results` array directly; inline and `allOf`-composed wrappers + are rejected, consistent with `xgen-IPA-110-collections-use-paginated-prefix` and + `xgen-IPA-110-collections-response-define-results-array` + - Inline Operation schemas are violations, the response must reference the predefined + `OperationResponse` schema + - Responses with `x-xgen-IPA-exception` for this rule at the response level are excluded + from validation + +#### xgen-IPA-132-operation-response-must-not-use-composition + + ![warn](https://img.shields.io/badge/warning-yellow) +The OperationResponse schema must define its properties directly. There is one and only one +OperationResponse, so allOf, oneOf and anyOf composition is a violation. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - The schema must not use allOf, oneOf or anyOf composition + - The other OperationResponse schema rules skip composed schemas, relying on this rule to + reject them + - Schemas with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operation-response-must-define-required-fields + + ![warn](https://img.shields.io/badge/warning-yellow) +The OperationResponse schema must define every field the IPA-132 standard marks as always +required: operationId, status, operationType, createdAt, updatedAt and expiresAt. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - Each field listed in the rule's functionOptions must be defined and listed as required + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - For expiresAt, only the field declaration is validated; the retention behavior and the + 404 after expiry are backend-defined + - Schemas with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operation-response-must-define-optional-fields + + ![warn](https://img.shields.io/badge/warning-yellow) +The OperationResponse schema must define every field the IPA-132 standard marks as +conditionally present, and none of them may be listed as required: customMethod, error, +resultHref and retryAfterSeconds are present only under runtime conditions. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - Each field listed in the rule's functionOptions must be defined and must not be listed + as required: the condition under which it is present is a runtime state that cannot be + validated statically + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - Schemas with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operation-response-must-use-standard-enum-values + + ![warn](https://img.shields.io/badge/warning-yellow) +Every enum field of the OperationResponse schema must use exactly the standard enum values +defined by IPA-132: the status lifecycle enum, the operationType enum and the retryStrategy +enum of the error object. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - Each enum field listed in the rule's functionOptions must use exactly the listed values, + in any order + - Fields that are not defined are skipped: top-level field presence is validated by the + required-fields and optional-fields rules + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - Schemas with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operation-response-must-define-nested-object-structure + + ![warn](https://img.shields.io/badge/warning-yellow) +Every nested object field of the OperationResponse schema must define the properties the +IPA-132 standard requires of it: the error object must define code, message and retryable. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - Each nested object field listed in the rule's functionOptions must define the listed + properties + - Fields that are not defined are skipped: their presence is validated by the + optional-fields rule + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - Schemas with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operations-should-expose-status-fields + + ![warn](https://img.shields.io/badge/warning-yellow) +An Operation should expose statusMessage, progress, and estimatedCompletionTime so clients +can reason about in-flight work. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to the OperationResponse component schema + - The schema should define the fields listed in the rule's functionOptions: + `statusMessage`, `progress` and `estimatedCompletionTime` + - The progress object should define `completed`, `total` and `unit` properties + - Kept separate from the must-level rules because it enforces should-level guidance + - Composed schemas are skipped: composition is rejected by the must-not-use-composition + rule + - Schemas with `x-xgen-IPA-exception` for this rule are excluded from validation + diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationEndpointsMustReturnOperationResponse.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationEndpointsMustReturnOperationResponse.js new file mode 100644 index 0000000000..1883f814de --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationEndpointsMustReturnOperationResponse.js @@ -0,0 +1,100 @@ +import { resolveObject } from './utils/componentUtils.js'; +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { getSchemaNameFromRef } from './utils/methodUtils.js'; +import { + isOperationsCollectionPath, + isSingleOperationPath, + OPERATION_RESPONSE_SCHEMA_NAME, +} from './utils/longRunningOperations.js'; + +const SINGLE_OPERATION_ERROR_MESSAGE = `The Operation endpoint must return the ${OPERATION_RESPONSE_SCHEMA_NAME} schema.`; +const OPERATIONS_COLLECTION_ERROR_MESSAGE = `The Operations collection endpoint must return a paginated response whose results reference the ${OPERATION_RESPONSE_SCHEMA_NAME} schema.`; +const MISSING_SCHEMA_ERROR_MESSAGE = 'The Operations endpoint must define a JSON response schema.'; + +const COMPONENT_SCHEMAS_REF_PREFIX = '#/components/schemas/'; + +/** + * Checks that Operations endpoints defined by IPA-132 return the OperationResponse schema: the + * single Operation endpoint must reference it directly, and the Operations collection endpoint + * must reference a paginated wrapper schema whose results items reference it. + * + * @param {string} input - The response status code of the GET method + * @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 resourcePath = path[1]; + const responseCode = input; + + if (!responseCode.startsWith('2')) { + return; + } + const isSingleOperation = isSingleOperationPath(resourcePath); + if (!isSingleOperation && !isOperationsCollectionPath(resourcePath)) { + return; + } + + // Work on the unresolved document, where schema references are still visible by name + const oas = documentInventory.unresolved; + const response = resolveObject(oas, path); + if (!response) { + return; + } + + const errors = checkViolationsAndReturnErrors(isSingleOperation, response, oas, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, response, path); +}; + +function checkViolationsAndReturnErrors(isSingleOperation, response, oas, path, ruleName) { + try { + const jsonContentEntries = Object.entries(response.content ?? {}).filter(([mediaType]) => + mediaType.endsWith('json') + ); + // A 2xx response without a JSON media type cannot return the OperationResponse schema + if (jsonContentEntries.length === 0) { + return [{ path, message: MISSING_SCHEMA_ERROR_MESSAGE }]; + } + + const errors = []; + for (const [mediaType, content] of jsonContentEntries) { + const mediaTypePath = [...path, 'content', mediaType]; + if (!content.schema) { + errors.push({ path: mediaTypePath, message: MISSING_SCHEMA_ERROR_MESSAGE }); + continue; + } + + if (isSingleOperation) { + if (!schemaReferencesOperationResponse(content.schema)) { + errors.push({ path: mediaTypePath, message: SINGLE_OPERATION_ERROR_MESSAGE }); + } + continue; + } + + // The Operations collection response must reference a paginated wrapper schema that defines + // the results array directly, the shape accepted by the IPA-110 pagination rules: inline + // and allOf-composed wrappers are rejected, consistent with + // xgen-IPA-110-collections-use-paginated-prefix and + // xgen-IPA-110-collections-response-define-results-array + const wrapperSchema = getReferencedSchema(content.schema, oas); + const resultsItems = wrapperSchema?.properties?.results?.items; + if (!(resultsItems && schemaReferencesOperationResponse(resultsItems))) { + errors.push({ path: mediaTypePath, message: OPERATIONS_COLLECTION_ERROR_MESSAGE }); + } + } + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} + +function schemaReferencesOperationResponse(schema) { + return schema.$ref !== undefined && getSchemaNameFromRef(schema.$ref) === OPERATION_RESPONSE_SCHEMA_NAME; +} + +function getReferencedSchema(schema, oas) { + if (schema.$ref === undefined || !schema.$ref.startsWith(COMPONENT_SCHEMAS_REF_PREFIX)) { + return undefined; + } + return oas.components?.schemas?.[getSchemaNameFromRef(schema.$ref)]; +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineNestedObjectStructure.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineNestedObjectStructure.js new file mode 100644 index 0000000000..59c8d04a9b --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineNestedObjectStructure.js @@ -0,0 +1,51 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { usesComposition } from './utils/longRunningOperations.js'; + +/** + * Checks that every nested object field of the OperationResponse schema defined by IPA-132 + * defines the properties the standard requires of it. The object fields and their properties are + * provided declaratively through the rule's functionOptions. Fields that are not defined are + * skipped: their presence is validated by the optional-fields rule. + * + * @param {object} input - The OperationResponse schema object + * @param {object} opts - The rule's functionOptions, carrying the object fields and their properties + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, opts, { path, rule }) => { + // Composed schemas are rejected by the must-not-use-composition rule + if (usesComposition(input)) { + return; + } + const ruleName = rule.name; + const errors = checkViolationsAndReturnErrors(input, opts.objects, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(schema, objects, path, ruleName) { + try { + const schemaProperties = schema.properties ?? {}; + const errors = []; + + for (const { field, properties } of objects) { + const objectSchema = schemaProperties[field]; + // Absent fields are the optional-fields rule's concern + if (!objectSchema) { + continue; + } + + const objectProperties = objectSchema.properties ?? {}; + for (const property of properties) { + if (!objectProperties[property]) { + errors.push({ + path: [...path, 'properties', field], + message: `The ${field} object must define a ${property} property.`, + }); + } + } + } + + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineOptionalFields.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineOptionalFields.js new file mode 100644 index 0000000000..ca1679fdc9 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineOptionalFields.js @@ -0,0 +1,45 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { usesComposition } from './utils/longRunningOperations.js'; + +/** + * Checks that the OperationResponse schema defined by IPA-132 defines every field the standard + * marks as conditionally present, and that none of them is listed as required: each field is + * present only under a runtime condition that cannot be validated statically. The field names + * and their conditions are provided declaratively through the rule's functionOptions. + * + * @param {object} input - The OperationResponse schema object + * @param {object} opts - The rule's functionOptions, carrying the field names and conditions + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, opts, { path, rule }) => { + // Composed schemas are rejected by the must-not-use-composition rule + if (usesComposition(input)) { + return; + } + const ruleName = rule.name; + const errors = checkViolationsAndReturnErrors(input, opts.fields, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(schema, fields, path, ruleName) { + try { + const properties = schema.properties ?? {}; + const required = schema.required ?? []; + const errors = []; + + for (const { name, condition } of fields) { + if (!properties[name]) { + errors.push({ path, message: `OperationResponse must define the ${name} property.` }); + } else if (required.includes(name)) { + errors.push({ + path: [...path, 'properties', name], + message: `The ${name} property must not be listed as required. It is present only ${condition}, which cannot be validated statically.`, + }); + } + } + + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineRequiredFields.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineRequiredFields.js new file mode 100644 index 0000000000..53a09ba2a2 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustDefineRequiredFields.js @@ -0,0 +1,44 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { usesComposition } from './utils/longRunningOperations.js'; + +/** + * Checks that the OperationResponse schema defined by IPA-132 defines every field the standard + * marks as always required, each listed in the schema's required array. The field names are + * provided declaratively through the rule's functionOptions. + * + * @param {object} input - The OperationResponse schema object + * @param {object} opts - The rule's functionOptions, carrying the required field names + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, opts, { path, rule }) => { + // Composed schemas are rejected by the must-not-use-composition rule + if (usesComposition(input)) { + return; + } + const ruleName = rule.name; + const errors = checkViolationsAndReturnErrors(input, opts.fields, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(schema, fields, path, ruleName) { + try { + const properties = schema.properties ?? {}; + const required = schema.required ?? []; + const errors = []; + + for (const field of fields) { + if (!properties[field]) { + errors.push({ path, message: `OperationResponse must define the ${field} property.` }); + } else if (!required.includes(field)) { + errors.push({ + path: [...path, 'properties', field], + message: `The ${field} property must be listed as required.`, + }); + } + } + + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustNotUseComposition.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustNotUseComposition.js new file mode 100644 index 0000000000..3c045cfd85 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustNotUseComposition.js @@ -0,0 +1,31 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { usesComposition } from './utils/longRunningOperations.js'; + +const COMPOSITION_ERROR_MESSAGE = + 'OperationResponse must define its properties directly, without allOf, oneOf or anyOf composition.'; + +/** + * Checks that the OperationResponse schema defined by IPA-132 is a single flat schema: there is + * one and only one OperationResponse, so allOf, oneOf and anyOf composition is a violation. The + * other OperationResponse schema rules skip composed schemas, relying on this rule to reject them. + * + * @param {object} input - The OperationResponse schema object + * @param {object} _ - Unused + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, _, { path, rule }) => { + const ruleName = rule.name; + const errors = checkViolationsAndReturnErrors(input, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(schema, path, ruleName) { + try { + if (usesComposition(schema)) { + return [{ path, message: COMPOSITION_ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustUseStandardEnumValues.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustUseStandardEnumValues.js new file mode 100644 index 0000000000..ea851ab181 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationResponseMustUseStandardEnumValues.js @@ -0,0 +1,57 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { isExactEnumMatch, usesComposition } from './utils/longRunningOperations.js'; + +/** + * Checks that every enum field of the OperationResponse schema defined by IPA-132 uses exactly + * the standard enum values, in any order. The field names and expected values are provided + * declaratively through the rule's functionOptions. Fields that are not defined are skipped: + * their presence is validated by the required-fields and optional-fields rules. + * + * @param {object} input - The OperationResponse schema object + * @param {object} opts - The rule's functionOptions, carrying the enum fields and their values + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, opts, { path, rule }) => { + // Composed schemas are rejected by the must-not-use-composition rule + if (usesComposition(input)) { + return; + } + const ruleName = rule.name; + const errors = checkViolationsAndReturnErrors(input, opts.enums, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(schema, enums, path, ruleName) { + try { + const properties = schema.properties ?? {}; + const errors = []; + + for (const { field, parent, values } of enums) { + let property; + let propertyPath; + if (parent) { + const parentProperty = properties[parent]; + property = parentProperty?.properties?.[field]; + propertyPath = [...path, 'properties', parent]; + } else { + property = properties[field]; + propertyPath = [...path, 'properties', field]; + } + + // Absent fields are the required-fields and optional-fields rules' concern + if (!property) { + continue; + } + if (!isExactEnumMatch(property.enum, values)) { + errors.push({ + path: propertyPath, + message: `The ${field} field must use exactly the enum [${values.join(', ')}].`, + }); + } + } + + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsShouldExposeStatusFields.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsShouldExposeStatusFields.js new file mode 100644 index 0000000000..fb58a55489 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsShouldExposeStatusFields.js @@ -0,0 +1,53 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { usesComposition } from './utils/longRunningOperations.js'; + +/** + * Checks that the OperationResponse schema defined by IPA-132 exposes the optional progress + * fields statusMessage, progress and estimatedCompletionTime, so clients can reason about + * in-flight work. The field names and nested object structures are provided declaratively + * through the rule's functionOptions. + * + * @param {object} input - The OperationResponse schema object + * @param {object} opts - The rule's functionOptions, carrying the field names and nested objects + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, opts, { path, rule }) => { + // Composed schemas are rejected by the must-not-use-composition rule + if (usesComposition(input)) { + return; + } + const ruleName = rule.name; + const errors = checkViolationsAndReturnErrors(input, opts, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, input, path); +}; + +function checkViolationsAndReturnErrors(schema, { fields, objects }, path, ruleName) { + try { + const properties = schema.properties ?? {}; + const errors = []; + for (const field of fields) { + if (!properties[field]) { + errors.push({ path, message: `OperationResponse should define the ${field} property.` }); + } + } + + for (const { field, properties: objectFields } of objects) { + const objectSchema = properties[field]; + if (!objectSchema) { + continue; + } + const objectProperties = objectSchema.properties ?? {}; + for (const objectField of objectFields) { + if (!objectProperties[objectField]) { + errors.push({ + path: [...path, 'properties', field], + message: `The ${field} object should define the ${objectField} property.`, + }); + } + } + } + return errors; + } 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 index 7e82cc02b6..9dfd2e4e3e 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js +++ b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js @@ -100,3 +100,33 @@ export function operationsSegmentIsLeaf(path) { const trailingSegments = segments.slice(firstIndex + 1); return trailingSegments.length === 0 || (trailingSegments.length === 1 && isPathParam(trailingSegments[0])); } + +export const OPERATION_RESPONSE_SCHEMA_NAME = 'OperationResponse'; + +/** + * Checks if the enum values defined in a schema match an expected set of values exactly, in any + * order. + * + * @param {string[]|undefined} enumValues the enum values defined in the schema + * @param {string[]} expectedValues the expected enum values + * @returns {boolean} true if the enum values match the expected values exactly + */ +export function isExactEnumMatch(enumValues, expectedValues) { + return ( + Array.isArray(enumValues) && + enumValues.length === expectedValues.length && + expectedValues.every((value) => enumValues.includes(value)) + ); +} + +/** + * Checks if a schema is composed through allOf, oneOf or anyOf. The OperationResponse schema + * defined by IPA-132 is a single flat schema, so composition is not allowed and the schema rules + * reject or skip composed schemas. + * + * @param {object} schema the schema to evaluate + * @returns {boolean} true if the schema uses allOf, oneOf or anyOf composition + */ +export function usesComposition(schema) { + return schema.allOf !== undefined || schema.oneOf !== undefined || schema.anyOf !== undefined; +}