diff --git a/tools/spectral/ipa/__tests__/IPA111EffectiveFieldsReadOnly.test.js b/tools/spectral/ipa/__tests__/IPA111EffectiveFieldsReadOnly.test.js new file mode 100644 index 0000000000..ba2b5f3820 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA111EffectiveFieldsReadOnly.test.js @@ -0,0 +1,146 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-111-effective-fields-read-only', [ + { + name: 'valid effective field with readOnly: true', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + effectiveState: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid non-effective field without readOnly', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + state: { type: 'string' }, + effective: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid effective field without readOnly', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + effectiveState: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-111-effective-fields-read-only', + message: 'Effective-value fields must be marked as readOnly: true.', + path: ['components', 'schemas', 'Schema', 'properties', 'effectiveState'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid effective field with readOnly: false', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + effectiveState: { type: 'string', readOnly: false }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-111-effective-fields-read-only', + message: 'Effective-value fields must be marked as readOnly: true.', + path: ['components', 'schemas', 'Schema', 'properties', 'effectiveState'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid effective field in request schema', + document: { + paths: { + '/resources': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'object', + properties: { + effectiveState: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-111-effective-fields-read-only', + message: 'Effective-value fields represent server-computed state and must not appear in request schemas.', + path: [ + 'paths', + '/resources', + 'post', + 'requestBody', + 'content', + 'application/vnd.atlas.2024-01-01+json', + 'schema', + 'properties', + 'effectiveState', + ], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid effective field without readOnly - exception', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + effectiveState: { + type: 'string', + 'x-xgen-IPA-exception': { + 'xgen-IPA-111-effective-fields-read-only': 'Reason', + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA111OptionalBooleanFieldsDefaultFalse.test.js b/tools/spectral/ipa/__tests__/IPA111OptionalBooleanFieldsDefaultFalse.test.js new file mode 100644 index 0000000000..4512d78d52 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA111OptionalBooleanFieldsDefaultFalse.test.js @@ -0,0 +1,193 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-111-optional-boolean-fields-default-false', [ + { + name: 'valid optional boolean field with default false', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + enabled: { type: 'boolean', default: false }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid required boolean field without default', + document: { + components: { + schemas: { + Schema: { + type: 'object', + required: ['enabled'], + properties: { + enabled: { type: 'boolean' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid non-boolean field without default', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid optional boolean field without default', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-111-optional-boolean-fields-default-false', + message: 'Optional boolean fields must default to false.', + path: ['components', 'schemas', 'Schema', 'properties', 'enabled'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid optional boolean field with default true', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + enabled: { type: 'boolean', default: true }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-111-optional-boolean-fields-default-false', + message: 'Optional boolean fields must default to false.', + path: ['components', 'schemas', 'Schema', 'properties', 'enabled'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid optional boolean field in request and response', + document: { + paths: { + '/resources': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'object', + properties: { + paused: { type: 'boolean' }, + }, + }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'object', + properties: { + hidden: { type: 'boolean', default: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-111-optional-boolean-fields-default-false', + message: 'Optional boolean fields must default to false.', + path: [ + 'paths', + '/resources', + 'post', + 'requestBody', + 'content', + 'application/vnd.atlas.2024-01-01+json', + 'schema', + 'properties', + 'paused', + ], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-111-optional-boolean-fields-default-false', + message: 'Optional boolean fields must default to false.', + path: [ + 'paths', + '/resources', + 'post', + 'responses', + '201', + 'content', + 'application/vnd.atlas.2024-01-01+json', + 'schema', + 'properties', + 'hidden', + ], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid optional boolean field without default - exception', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + enabled: { + type: 'boolean', + 'x-xgen-IPA-exception': { + 'xgen-IPA-111-optional-boolean-fields-default-false': 'Reason', + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA111OptionalFieldsNoDefault.test.js b/tools/spectral/ipa/__tests__/IPA111OptionalFieldsNoDefault.test.js new file mode 100644 index 0000000000..fb320c0f1b --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA111OptionalFieldsNoDefault.test.js @@ -0,0 +1,194 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-111-optional-fields-no-default', [ + { + name: 'valid optional field without default', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid required field with default', + document: { + components: { + schemas: { + Schema: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', default: 'value' }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid optional field with default and server-computed extension', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + name: { + type: 'string', + default: 'value', + 'x-xgen-server-computed-when-client-omitted': true, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid optional boolean field with default (handled by other rule)', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + enabled: { type: 'boolean', default: true }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid optional field with default', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + name: { type: 'string', default: 'value' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-111-optional-fields-no-default', + message: + 'Optional fields must not define a default value. Remove the default or mark the field with x-xgen-server-computed-when-client-omitted if the server computes it when omitted.', + path: ['components', 'schemas', 'Schema', 'properties', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid optional field with default in request and response', + document: { + paths: { + '/resources': { + post: { + requestBody: { + content: { + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'object', + properties: { + size: { type: 'integer', default: 5 }, + }, + }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/vnd.atlas.2024-01-01+json': { + schema: { + type: 'object', + properties: { + region: { type: 'string', default: 'US_EAST' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-111-optional-fields-no-default', + message: + 'Optional fields must not define a default value. Remove the default or mark the field with x-xgen-server-computed-when-client-omitted if the server computes it when omitted.', + path: [ + 'paths', + '/resources', + 'post', + 'requestBody', + 'content', + 'application/vnd.atlas.2024-01-01+json', + 'schema', + 'properties', + 'size', + ], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-111-optional-fields-no-default', + message: + 'Optional fields must not define a default value. Remove the default or mark the field with x-xgen-server-computed-when-client-omitted if the server computes it when omitted.', + path: [ + 'paths', + '/resources', + 'post', + 'responses', + '201', + 'content', + 'application/vnd.atlas.2024-01-01+json', + 'schema', + 'properties', + 'region', + ], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid optional field with default - exception', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + name: { + type: 'string', + default: 'value', + 'x-xgen-IPA-exception': { + 'xgen-IPA-111-optional-fields-no-default': 'Reason', + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index e15fff8b6f..ab6bcfc605 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -8,6 +8,7 @@ extends: - ./rulesets/IPA-108.yaml - ./rulesets/IPA-109.yaml - ./rulesets/IPA-110.yaml + - ./rulesets/IPA-111.yaml - ./rulesets/IPA-112.yaml - ./rulesets/IPA-113.yaml - ./rulesets/IPA-114.yaml @@ -89,3 +90,76 @@ overrides: rules: xgen-IPA-107-operation-id-length: 'off' xgen-IPA-107-valid-operation-id: 'off' + - files: + - '**#/components/schemas/S3LogIntegrationRequest/allOf/1/properties/useLegacyPathStructure' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/S3LogIntegrationResponse/allOf/1/properties/useLegacyPathStructure' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SandboxConfigRequest/properties/enabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SandboxConfigResponse/properties/enabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SandboxConfigUpdateRequest/properties/enabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SearchDeploymentAuditView/properties/isGlobalAdmin' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SearchHostStatusDetail/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SearchIndexResponse/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SearchMainIndexStatusDetail/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SearchStagedIndexStatusDetail/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/ServiceAccountGroupEvents/properties/isGlobalAdmin' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/ServiceAccountOrgEvents/properties/isGlobalAdmin' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsAutoscaling/properties/enabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsModifyStreamProcessor/properties/failoverEnabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsModifyStreamProcessorOptions/properties/resumeFromCheckpoint' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsProcessor/properties/failoverEnabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsProcessorWithStats/properties/eligibleForFailover' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsProcessorWithStats/properties/failoverEnabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsStartProcessorFailover/properties/clearCheckpoint' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsStartProcessorFailover/properties/dryRun' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/StreamsStartStreamProcessorWith/properties/resumeFromCheckpoint' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SynonymMappingStatusDetail/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TeamEvent/properties/isGlobalAdmin' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TeamEventViewForNdsGroup/properties/isGlobalAdmin' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TeamNotification/properties/emailEnabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TeamNotification/properties/smsEnabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TextSearchHostStatusDetail/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TextSearchIndexStatusDetail/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TimeMetricEventView/properties/isGlobalAdmin' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TokenFilterWordDelimiterGraph/properties/delimiterOptions/properties/generateNumberParts' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TokenFilterWordDelimiterGraph/properties/delimiterOptions/properties/generateWordParts' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TokenFilterWordDelimiterGraph/properties/delimiterOptions/properties/preserveOriginal' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TokenFilterWordDelimiterGraph/properties/delimiterOptions/properties/splitOnCaseChange' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TokenFilterWordDelimiterGraph/properties/delimiterOptions/properties/splitOnNumerics' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TokenFilterWordDelimiterGraph/properties/delimiterOptions/properties/stemEnglishPossessive' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TokenFilterWordDelimiterGraph/properties/protectedWords/properties/ignoreCase' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/UsageDetailsFilterRequest/properties/includeZeroCentLineItems' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/UserEventViewForNdsGroup/properties/isGlobalAdmin' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/UserEventViewForOrg/properties/isGlobalAdmin' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/UserNotification/properties/emailEnabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/UserNotification/properties/smsEnabled' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/VectorSearchHostStatusDetail/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/VectorSearchIndexStatusDetail/properties/queryable' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenFilterstopword/properties/ignoreCase' # to be addressed in CLOUDP-439952 + rules: + xgen-IPA-111-optional-boolean-fields-default-false: 'off' + - files: + - '**#/components/schemas/SearchIndex/allOf/1/properties/analyzer' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SearchIndex/allOf/1/properties/searchAnalyzer' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/SearchIndexDefinition/properties/numPartitions' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/ServerlessProviderSettings/properties/providerName' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TextSearchIndexDefinition/properties/analyzer' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TextSearchIndexDefinition/properties/numPartitions' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/TextSearchIndexDefinition/properties/searchAnalyzer' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/UserCert/properties/monthsUntilExpiration' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/VectorSearchIndexDefinition/properties/numPartitions' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenFilterasciiFolding/properties/originalTokens' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenFilterdaitchMokotoffSoundex/properties/originalTokens' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenFilteredgeGram/properties/termNotInBounds' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenFiltericuNormalizer/properties/normalizationForm' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenFilterlength/properties/max' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenFilterlength/properties/min' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenFilternGram/properties/termNotInBounds' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenizerstandard/properties/maxTokenLength' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenizeruaxUrlEmail/properties/maxTokenLength' # to be addressed in CLOUDP-439952 + - '**#/components/schemas/tokenizerwhitespace/properties/maxTokenLength' # to be addressed in CLOUDP-439952 + rules: + xgen-IPA-111-optional-fields-no-default: 'off' + - files: + - '**#/components/schemas/ServerlessProviderSettings/properties/effectiveInstanceSizeName' # to be addressed in CLOUDP-439952 + rules: + xgen-IPA-111-effective-fields-read-only: 'off' diff --git a/tools/spectral/ipa/rulesets/IPA-111.yaml b/tools/spectral/ipa/rulesets/IPA-111.yaml new file mode 100644 index 0000000000..c3f3ffece6 --- /dev/null +++ b/tools/spectral/ipa/rulesets/IPA-111.yaml @@ -0,0 +1,66 @@ +# IPA-111: Default Values +# https://mongodb.github.io/ipa/111 + +functions: + - IPA111OptionalFieldsNoDefault + - IPA111EffectiveFieldsReadOnly + - IPA111OptionalBooleanFieldsDefaultFalse + +rules: + xgen-IPA-111-optional-fields-no-default: + description: | + Optional fields must not define a default value. + + ##### Implementation details + Rule checks for the following conditions: + - Applies to optional (non-required) schema properties in components, request bodies and responses + - Boolean fields are exempt (covered by xgen-IPA-111-optional-boolean-fields-default-false) + - Fields marked with the x-xgen-server-computed-when-client-omitted extension are exempt + - Fails if the field defines a default value + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-111-optional-fields-no-default' + severity: error + resolved: false + given: + - '$.components.schemas..properties' + - '$.paths..requestBody.content[?(@property.match(/json$/i))].schema..properties' + - '$.paths..responses..content[?(@property.match(/json$/i))].schema..properties' + then: + field: '@key' + function: 'IPA111OptionalFieldsNoDefault' + xgen-IPA-111-effective-fields-read-only: + description: | + Effective-value fields must be marked as readOnly: true and must not appear in request schemas. + + ##### Implementation details + Rule checks for the following conditions: + - Applies to schema properties whose name starts with the "effective" prefix + - Fails if the field appears in a request schema + - Fails if the field is not marked with readOnly: true + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-111-effective-fields-read-only' + severity: error + resolved: false + given: + - '$.components.schemas..properties' + - '$.paths..requestBody.content[?(@property.match(/json$/i))].schema..properties' + - '$.paths..responses..content[?(@property.match(/json$/i))].schema..properties' + then: + field: '@key' + function: 'IPA111EffectiveFieldsReadOnly' + xgen-IPA-111-optional-boolean-fields-default-false: + description: | + Optional boolean fields must default to false. + + ##### Implementation details + Rule checks for the following conditions: + - Applies to optional (non-required) boolean schema properties in components, request bodies and responses + - Fails if the field does not define a default value of false + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-111-optional-boolean-fields-default-false' + severity: error + resolved: false + given: + - '$.components.schemas..properties' + - '$.paths..requestBody.content[?(@property.match(/json$/i))].schema..properties' + - '$.paths..responses..content[?(@property.match(/json$/i))].schema..properties' + then: + field: '@key' + function: 'IPA111OptionalBooleanFieldsDefaultFalse' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 3053dea3fb..64de5a8d9e 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -776,6 +776,45 @@ Rule checks for the following conditions: +### IPA-111 + +Rules are based on [https://mongodb.github.io/ipa/111](https://mongodb.github.io/ipa/111). + +#### xgen-IPA-111-optional-fields-no-default + + ![error](https://img.shields.io/badge/error-red) +Optional fields must not define a default value. + +##### Implementation details +Rule checks for the following conditions: + - Applies to optional (non-required) schema properties in components, request bodies and responses + - Boolean fields are exempt (covered by xgen-IPA-111-optional-boolean-fields-default-false) + - Fields marked with the x-xgen-server-computed-when-client-omitted extension are exempt + - Fails if the field defines a default value + +#### xgen-IPA-111-effective-fields-read-only + + ![error](https://img.shields.io/badge/error-red) +Effective-value fields must be marked as readOnly: true and must not appear in request schemas. + +##### Implementation details +Rule checks for the following conditions: + - Applies to schema properties whose name starts with the "effective" prefix + - Fails if the field appears in a request schema + - Fails if the field is not marked with readOnly: true + +#### xgen-IPA-111-optional-boolean-fields-default-false + + ![error](https://img.shields.io/badge/error-red) +Optional boolean fields must default to false. + +##### Implementation details +Rule checks for the following conditions: + - Applies to optional (non-required) boolean schema properties in components, request bodies and responses + - Fails if the field does not define a default value of false + + + ### IPA-112 Rules are based on [https://mongodb.github.io/ipa/112](https://mongodb.github.io/ipa/112). diff --git a/tools/spectral/ipa/rulesets/functions/IPA111EffectiveFieldsReadOnly.js b/tools/spectral/ipa/rulesets/functions/IPA111EffectiveFieldsReadOnly.js new file mode 100644 index 0000000000..365b9283c5 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA111EffectiveFieldsReadOnly.js @@ -0,0 +1,41 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { pathIsForRequestVersion, resolveObject } from './utils/componentUtils.js'; + +const EFFECTIVE_PREFIX_REGEX = /^effective[A-Z]/; +const REQUEST_ERROR_MESSAGE = + 'Effective-value fields represent server-computed state and must not appear in request schemas.'; +const READ_ONLY_ERROR_MESSAGE = 'Effective-value fields must be marked as readOnly: true.'; + +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.unresolved; + const property = resolveObject(oas, path); + + // Skip schema references ($ref): + // Referenced schemas are validated separately to prevent duplicate violations + if (!property) { + return; + } + + // The rule only applies to effective-value fields, identified by the "effective" prefix + if (!EFFECTIVE_PREFIX_REGEX.test(input)) { + return; + } + + const errors = checkViolationsAndReturnErrors(property, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, property, path); +}; + +function checkViolationsAndReturnErrors(property, path, ruleName) { + try { + if (pathIsForRequestVersion(path)) { + return [{ path, message: REQUEST_ERROR_MESSAGE }]; + } + if (property.readOnly !== true) { + return [{ path, message: READ_ONLY_ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA111OptionalBooleanFieldsDefaultFalse.js b/tools/spectral/ipa/rulesets/functions/IPA111OptionalBooleanFieldsDefaultFalse.js new file mode 100644 index 0000000000..b694c63cad --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA111OptionalBooleanFieldsDefaultFalse.js @@ -0,0 +1,36 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { isRequiredProperty } from './utils/schemaUtils.js'; + +const ERROR_MESSAGE = 'Optional boolean fields must default to false.'; + +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.unresolved; + const property = resolveObject(oas, path); + + // Skip schema references ($ref) and non-boolean fields: + // Referenced schemas are validated separately to prevent duplicate violations + if (!property || property.type !== 'boolean') { + return; + } + + // The rule only applies to optional fields + if (isRequiredProperty(oas, path)) { + return; + } + + const errors = checkViolationsAndReturnErrors(property, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, property, path); +}; + +function checkViolationsAndReturnErrors(property, path, ruleName) { + try { + if (property.default !== false) { + return [{ path, message: ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA111OptionalFieldsNoDefault.js b/tools/spectral/ipa/rulesets/functions/IPA111OptionalFieldsNoDefault.js new file mode 100644 index 0000000000..85c97a7e8f --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA111OptionalFieldsNoDefault.js @@ -0,0 +1,48 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { isRequiredProperty } from './utils/schemaUtils.js'; +import { hasServerComputedWhenClientOmittedExtension } from './utils/extensions.js'; + +const ERROR_MESSAGE = + 'Optional fields must not define a default value. Remove the default or mark the field with x-xgen-server-computed-when-client-omitted if the server computes it when omitted.'; + +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.unresolved; + const property = resolveObject(oas, path); + + // Skip schema references ($ref): + // Referenced schemas are validated separately to prevent duplicate violations + if (!property) { + return; + } + + // Boolean fields are handled by xgen-IPA-111-optional-boolean-fields-default-false + if (property.type === 'boolean') { + return; + } + + // The rule only applies to optional fields + if (isRequiredProperty(oas, path)) { + return; + } + + // Fields computed by the server when the client omits them are allowed to define a default + if (hasServerComputedWhenClientOmittedExtension(property)) { + return; + } + + const errors = checkViolationsAndReturnErrors(property, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, property, path); +}; + +function checkViolationsAndReturnErrors(property, path, ruleName) { + try { + if (property.default !== undefined) { + return [{ path, message: ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/extensions.js b/tools/spectral/ipa/rulesets/functions/utils/extensions.js index 29cc488496..c7f551d450 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/extensions.js +++ b/tools/spectral/ipa/rulesets/functions/utils/extensions.js @@ -1,5 +1,17 @@ export const VERB_OVERRIDE_EXTENSION = 'x-xgen-method-verb-override'; export const OPERATION_ID_OVERRIDE_EXTENSION = 'x-xgen-operation-id-override'; +export const SERVER_COMPUTED_WHEN_CLIENT_OMITTED_EXTENSION = 'x-xgen-server-computed-when-client-omitted'; + +/** + * Checks if the object has the extension "x-xgen-server-computed-when-client-omitted", marking a field + * whose value is computed by the server when the client omits it. + * + * @param object the object to evaluate + * @returns {boolean} true if the object has the extension, otherwise false + */ +export function hasServerComputedWhenClientOmittedExtension(object) { + return Object.keys(object).includes(SERVER_COMPUTED_WHEN_CLIENT_OMITTED_EXTENSION); +} /** * Checks if the object has an extension "x-xgen-method-verb-override" with the customMethod boolean set to true diff --git a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js index 5dfd8d0991..6ec0d09ffc 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js +++ b/tools/spectral/ipa/rulesets/functions/utils/schemaUtils.js @@ -1,3 +1,21 @@ +import { resolveObject } from './componentUtils.js'; + +/** + * Checks if a schema property is listed as required by its parent schema. + * + * Given the JSON path to a property (ending in `['properties', '']`), this resolves the + * enclosing schema object and inspects its `required` array. + * + * @param {Object} oas the OpenAPI Specification object to resolve against + * @param {string[]} propertyPath the JSON path to the property + * @returns {boolean} true if the property is listed in the parent schema's `required` array, false otherwise + */ +export function isRequiredProperty(oas, propertyPath) { + const propertyName = propertyPath[propertyPath.length - 1]; + const parentSchema = resolveObject(oas, propertyPath.slice(0, propertyPath.length - 2)); + return Array.isArray(parentSchema?.required) && parentSchema.required.includes(propertyName); +} + /** * Checks if the object has results property * @param {Object} schema