From 0bd2a99ee74872d3d672bf1fec29da824b042115 Mon Sep 17 00:00:00 2001 From: "mongodb-sage-bot[bot]" <247496174+mongodb-sage-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:18:18 +0000 Subject: [PATCH] CLOUDP-415688: add IPA-103, IPA-117 and IPA-131 Spectral rules ## Proposed changes Implements the outstanding Spectral rule candidates identified for the Terraform autogeneration IPA enhancements (CLOUDP-356095). - IPA-103 (new ruleset): `xgen-IPA-103-response-body-root-shape` (error) enforces that every JSON response body schema is an object with named properties, or a `oneOf` whose variants are all objects. It rejects arrays, primitives and objects whose only key is `additionalProperties`. IPA-110 collection envelopes (results/links/totalCount) satisfy the rule. - IPA-117: `xgen-IPA-117-oneof-property-descriptions-must-match` (warn) surfaces `oneOf` variants where a shared property's description diverges. - IPA-131 (new ruleset): per-extension structural validation that fires only when the extension is declared: - `xgen-IPA-131-array-semantic` (error): carrying property must be `type: array` and the value must be `list` or `set`. - `xgen-IPA-131-server-computed-when-client-omitted` (error): value must be a boolean and the property must not be listed as required. - `xgen-IPA-131-server-computed-immutable` (error): value must be a boolean and the property must be `readOnly: true`. New rulesets are wired into `ipa-spectral.yaml`, the shared extension constants are added to `functions/utils/extensions.js`, and the generated rulesets README is regenerated. _Jira ticket:_ CLOUDP-415688 ## Checklist - [ ] I have signed the [MongoDB CLA](https://www.mongodb.com/legal/contributor-agreement) - [x] I have added tests that prove my fix is effective or that my feature works ### Changes to Spectral - [x] I have read the [README](../tools/spectral/README.md) file for Spectral Updates --- .../IPA103ResponseBodyRootShape.test.js | 217 ++++++++++++++++++ ...OneOfPropertyDescriptionsMustMatch.test.js | 159 +++++++++++++ .../ipa/__tests__/IPA131ArraySemantic.test.js | 94 ++++++++ .../IPA131ServerComputedImmutable.test.js | 122 ++++++++++ ...131ServerComputedWhenClientOmitted.test.js | 127 ++++++++++ tools/spectral/ipa/ipa-spectral.yaml | 2 + tools/spectral/ipa/rulesets/IPA-103.yaml | 24 ++ tools/spectral/ipa/rulesets/IPA-117.yaml | 17 ++ tools/spectral/ipa/rulesets/IPA-131.yaml | 69 ++++++ tools/spectral/ipa/rulesets/README.md | 73 ++++++ .../functions/IPA103ResponseBodyRootShape.js | 51 ++++ ...PA117OneOfPropertyDescriptionsMustMatch.js | 59 +++++ .../rulesets/functions/IPA131ArraySemantic.js | 46 ++++ .../IPA131ServerComputedImmutable.js | 50 ++++ .../IPA131ServerComputedWhenClientOmitted.js | 51 ++++ .../rulesets/functions/utils/extensions.js | 2 + 16 files changed, 1163 insertions(+) create mode 100644 tools/spectral/ipa/__tests__/IPA103ResponseBodyRootShape.test.js create mode 100644 tools/spectral/ipa/__tests__/IPA117OneOfPropertyDescriptionsMustMatch.test.js create mode 100644 tools/spectral/ipa/__tests__/IPA131ArraySemantic.test.js create mode 100644 tools/spectral/ipa/__tests__/IPA131ServerComputedImmutable.test.js create mode 100644 tools/spectral/ipa/__tests__/IPA131ServerComputedWhenClientOmitted.test.js create mode 100644 tools/spectral/ipa/rulesets/IPA-103.yaml create mode 100644 tools/spectral/ipa/rulesets/IPA-131.yaml create mode 100644 tools/spectral/ipa/rulesets/functions/IPA103ResponseBodyRootShape.js create mode 100644 tools/spectral/ipa/rulesets/functions/IPA117OneOfPropertyDescriptionsMustMatch.js create mode 100644 tools/spectral/ipa/rulesets/functions/IPA131ArraySemantic.js create mode 100644 tools/spectral/ipa/rulesets/functions/IPA131ServerComputedImmutable.js create mode 100644 tools/spectral/ipa/rulesets/functions/IPA131ServerComputedWhenClientOmitted.js diff --git a/tools/spectral/ipa/__tests__/IPA103ResponseBodyRootShape.test.js b/tools/spectral/ipa/__tests__/IPA103ResponseBodyRootShape.test.js new file mode 100644 index 00000000000..ff704560832 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA103ResponseBodyRootShape.test.js @@ -0,0 +1,217 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-103-response-body-root-shape', [ + { + name: 'valid object response bodies', + document: { + paths: { + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-08-05+json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/resource': { + get: { + responses: { + 200: { + content: { + // Collection envelope per IPA-110 + 'application/vnd.atlas.2023-08-05+json': { + schema: { + type: 'object', + properties: { + results: { type: 'array', items: { type: 'object' } }, + links: { type: 'array', items: { type: 'object' } }, + totalCount: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid discriminated oneOf response body', + document: { + paths: { + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-08-05+json': { + schema: { + oneOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'string' } } }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'responses without a schema and non-JSON responses are ignored', + document: { + paths: { + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-08-05+json': { + example: 'no schema', + }, + 'text/csv': { + schema: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid response bodies', + document: { + paths: { + '/resource': { + get: { + responses: { + 200: { + content: { + // Array root + 'application/vnd.atlas.2023-08-05+json': { + schema: { type: 'array', items: { type: 'object' } }, + }, + // Primitive root + 'application/vnd.atlas.2024-08-05+json': { + schema: { type: 'string' }, + }, + }, + }, + }, + }, + }, + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + // Object with only additionalProperties + 'application/vnd.atlas.2023-08-05+json': { + schema: { type: 'object', additionalProperties: { type: 'string' } }, + }, + // oneOf with a non-object variant + 'application/vnd.atlas.2024-08-05+json': { + schema: { + oneOf: [{ type: 'object', properties: { a: { type: 'string' } } }, { type: 'string' }], + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-103-response-body-root-shape', + message: + 'Response bodies must be a JSON object with named properties, or a oneOf whose variants are all objects.', + path: ['paths', '/resource', 'get', 'responses', '200', 'content', 'application/vnd.atlas.2023-08-05+json'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-103-response-body-root-shape', + message: + 'Response bodies must be a JSON object with named properties, or a oneOf whose variants are all objects.', + path: ['paths', '/resource', 'get', 'responses', '200', 'content', 'application/vnd.atlas.2024-08-05+json'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-103-response-body-root-shape', + message: + 'Response bodies must be a JSON object with named properties, or a oneOf whose variants are all objects.', + path: [ + 'paths', + '/resource/{id}', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2023-08-05+json', + ], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-103-response-body-root-shape', + message: + 'Response bodies must be a JSON object with named properties, or a oneOf whose variants are all objects.', + path: [ + 'paths', + '/resource/{id}', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid response body with exception', + document: { + paths: { + '/resource': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-08-05+json': { + schema: { type: 'array', items: { type: 'object' } }, + 'x-xgen-IPA-exception': { + 'xgen-IPA-103-response-body-root-shape': 'Reason', + }, + }, + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA117OneOfPropertyDescriptionsMustMatch.test.js b/tools/spectral/ipa/__tests__/IPA117OneOfPropertyDescriptionsMustMatch.test.js new file mode 100644 index 00000000000..ffcec252c19 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA117OneOfPropertyDescriptionsMustMatch.test.js @@ -0,0 +1,159 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-117-oneof-property-descriptions-must-match', [ + { + name: 'valid oneOf with matching shared property descriptions', + document: { + components: { + schemas: { + ExampleSchema: { + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string', description: 'The identifier.' }, + name: { type: 'string', description: 'The name.' }, + }, + }, + { + type: 'object', + properties: { + id: { type: 'string', description: 'The identifier.' }, + nickname: { type: 'string', description: 'The nickname.' }, + }, + }, + ], + }, + }, + }, + }, + errors: [], + }, + { + name: 'valid oneOf with a single variant', + document: { + components: { + schemas: { + ExampleSchema: { + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string', description: 'The identifier.' }, + }, + }, + ], + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid oneOf with diverging shared property descriptions', + document: { + components: { + schemas: { + ExampleSchema: { + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string', description: 'The identifier.' }, + status: { type: 'string', description: 'The status.' }, + }, + }, + { + type: 'object', + properties: { + id: { type: 'string', description: 'A unique identifier.' }, + status: { type: 'string', description: 'The current status.' }, + }, + }, + ], + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-117-oneof-property-descriptions-must-match', + message: + "Property 'id' has diverging descriptions across oneOf variants. Shared properties must use the same description.", + path: ['components', 'schemas', 'ExampleSchema'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-117-oneof-property-descriptions-must-match', + message: + "Property 'status' has diverging descriptions across oneOf variants. Shared properties must use the same description.", + path: ['components', 'schemas', 'ExampleSchema'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid oneOf where one variant omits the description', + document: { + components: { + schemas: { + ExampleSchema: { + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string', description: 'The identifier.' }, + }, + }, + { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + ], + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-117-oneof-property-descriptions-must-match', + message: + "Property 'id' has diverging descriptions across oneOf variants. Shared properties must use the same description.", + path: ['components', 'schemas', 'ExampleSchema'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid oneOf with exception', + document: { + components: { + schemas: { + ExampleSchema: { + 'x-xgen-IPA-exception': { + 'xgen-IPA-117-oneof-property-descriptions-must-match': 'Reason', + }, + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string', description: 'The identifier.' }, + }, + }, + { + type: 'object', + properties: { + id: { type: 'string', description: 'A different description.' }, + }, + }, + ], + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA131ArraySemantic.test.js b/tools/spectral/ipa/__tests__/IPA131ArraySemantic.test.js new file mode 100644 index 00000000000..02750b75768 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA131ArraySemantic.test.js @@ -0,0 +1,94 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const ERROR_MESSAGE = + 'The x-xgen-array-semantic extension must be set to one of list, set, and its carrying property must be type: array.'; + +testRule('xgen-IPA-131-array-semantic', [ + { + name: 'valid array-semantic extensions', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + tags: { type: 'array', items: { type: 'string' }, 'x-xgen-array-semantic': 'set' }, + names: { type: 'array', items: { type: 'string' }, 'x-xgen-array-semantic': 'list' }, + untagged: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid array-semantic on non-array property', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + tags: { type: 'string', 'x-xgen-array-semantic': 'set' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-131-array-semantic', + message: ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'tags'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid array-semantic value', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + tags: { type: 'array', items: { type: 'string' }, 'x-xgen-array-semantic': 'bag' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-131-array-semantic', + message: ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'tags'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid array-semantic with exception', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + tags: { + type: 'string', + 'x-xgen-array-semantic': 'set', + 'x-xgen-IPA-exception': { + 'xgen-IPA-131-array-semantic': 'Reason', + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA131ServerComputedImmutable.test.js b/tools/spectral/ipa/__tests__/IPA131ServerComputedImmutable.test.js new file mode 100644 index 00000000000..efcf515cc98 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA131ServerComputedImmutable.test.js @@ -0,0 +1,122 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const VALUE_ERROR_MESSAGE = 'The x-xgen-server-computed-immutable extension value must be a boolean.'; +const READ_ONLY_ERROR_MESSAGE = + 'A property with the x-xgen-server-computed-immutable extension must also be marked as readOnly: true.'; + +testRule('xgen-IPA-131-server-computed-immutable', [ + { + name: 'valid server-computed-immutable extension', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + computed: { type: 'string', readOnly: true, 'x-xgen-server-computed-immutable': true }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid non-boolean value', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + computed: { type: 'string', readOnly: true, 'x-xgen-server-computed-immutable': 'yes' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-131-server-computed-immutable', + message: VALUE_ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'computed'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid property not marked readOnly', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + computed: { type: 'string', 'x-xgen-server-computed-immutable': true }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-131-server-computed-immutable', + message: READ_ONLY_ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'computed'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid non-boolean value and not readOnly', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + computed: { type: 'string', 'x-xgen-server-computed-immutable': 0 }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-131-server-computed-immutable', + message: VALUE_ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'computed'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-131-server-computed-immutable', + message: READ_ONLY_ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'computed'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid property with exception', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + computed: { + type: 'string', + 'x-xgen-server-computed-immutable': true, + 'x-xgen-IPA-exception': { + 'xgen-IPA-131-server-computed-immutable': 'Reason', + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA131ServerComputedWhenClientOmitted.test.js b/tools/spectral/ipa/__tests__/IPA131ServerComputedWhenClientOmitted.test.js new file mode 100644 index 00000000000..84d938ef3bf --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA131ServerComputedWhenClientOmitted.test.js @@ -0,0 +1,127 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const VALUE_ERROR_MESSAGE = 'The x-xgen-server-computed-when-client-omitted extension value must be a boolean.'; +const REQUIRED_ERROR_MESSAGE = + "A property with the x-xgen-server-computed-when-client-omitted extension must not appear in the schema's required list."; + +testRule('xgen-IPA-131-server-computed-when-client-omitted', [ + { + name: 'valid server-computed-when-client-omitted extension', + document: { + components: { + schemas: { + Schema: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + computed: { type: 'string', 'x-xgen-server-computed-when-client-omitted': true }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid non-boolean value', + document: { + components: { + schemas: { + Schema: { + type: 'object', + properties: { + computed: { type: 'string', 'x-xgen-server-computed-when-client-omitted': 'yes' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-131-server-computed-when-client-omitted', + message: VALUE_ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'computed'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid property listed as required', + document: { + components: { + schemas: { + Schema: { + type: 'object', + required: ['computed'], + properties: { + computed: { type: 'string', 'x-xgen-server-computed-when-client-omitted': true }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-131-server-computed-when-client-omitted', + message: REQUIRED_ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'computed'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid non-boolean value and required', + document: { + components: { + schemas: { + Schema: { + type: 'object', + required: ['computed'], + properties: { + computed: { type: 'string', 'x-xgen-server-computed-when-client-omitted': 1 }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-131-server-computed-when-client-omitted', + message: VALUE_ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'computed'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-131-server-computed-when-client-omitted', + message: REQUIRED_ERROR_MESSAGE, + path: ['components', 'schemas', 'Schema', 'properties', 'computed'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid property with exception', + document: { + components: { + schemas: { + Schema: { + type: 'object', + required: ['computed'], + properties: { + computed: { + type: 'string', + 'x-xgen-server-computed-when-client-omitted': true, + 'x-xgen-IPA-exception': { + 'xgen-IPA-131-server-computed-when-client-omitted': 'Reason', + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index 89b0de6842a..9f155646a3e 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -1,6 +1,7 @@ extends: - ./rulesets/IPA-005.yaml - ./rulesets/IPA-102.yaml + - ./rulesets/IPA-103.yaml - ./rulesets/IPA-104.yaml - ./rulesets/IPA-105.yaml - ./rulesets/IPA-106.yaml @@ -20,6 +21,7 @@ extends: - ./rulesets/IPA-124.yaml - ./rulesets/IPA-125.yaml - ./rulesets/IPA-126.yaml + - ./rulesets/IPA-131.yaml - ./rulesets/IPA-132.yaml overrides: diff --git a/tools/spectral/ipa/rulesets/IPA-103.yaml b/tools/spectral/ipa/rulesets/IPA-103.yaml new file mode 100644 index 00000000000..fd444887894 --- /dev/null +++ b/tools/spectral/ipa/rulesets/IPA-103.yaml @@ -0,0 +1,24 @@ +# IPA-103: Resources +# https://mongodb.github.io/ipa/103 + +functions: + - IPA103ResponseBodyRootShape + +rules: + xgen-IPA-103-response-body-root-shape: + description: | + Response bodies must define a well-defined root shape: a JSON object with named properties, or a discriminated `oneOf` whose variants are all objects. + + ##### Implementation details + Rule checks for the following conditions: + - Applies to JSON response bodies that define a schema + - The resolved schema must be `type: object` with at least one named property, or a `oneOf` whose variants are all `type: object` + - Collection envelopes per IPA-110 (`results`, `links`, `totalCount`) satisfy the rule, as they are objects with named properties + - Rejects `type: array`, primitive types, and objects whose only keys are `additionalProperties` + - Responses with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-103-response-body-root-shape' + severity: error + given: '$.paths[*][*].responses[*].content' + then: + field: '@key' + function: 'IPA103ResponseBodyRootShape' diff --git a/tools/spectral/ipa/rulesets/IPA-117.yaml b/tools/spectral/ipa/rulesets/IPA-117.yaml index 4e515d8b5de..b6940e42aa9 100644 --- a/tools/spectral/ipa/rulesets/IPA-117.yaml +++ b/tools/spectral/ipa/rulesets/IPA-117.yaml @@ -15,6 +15,7 @@ functions: - IPA117OperationSummaryFormat - IPA117OperationSummaryStartsWith - IPA117OperationSummarySingleItemWording + - IPA117OneOfPropertyDescriptionsMustMatch aliases: OperationObject: @@ -410,3 +411,19 @@ rules: - a - specified - provided + xgen-IPA-117-oneof-property-descriptions-must-match: + description: | + Shared properties across `oneOf` variants should use consistent descriptions. + + ##### Implementation details + Rule checks for the following conditions: + - Applies to every `oneOf` schema in the spec + - For every property name shared across two or more variants, the property's `description` must be byte-equal across variants + - Some divergence may be intentional, so this rule surfaces cases for review without blocking + - Schemas with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-117-oneof-property-descriptions-must-match' + severity: warn + given: + - '$..oneOf' + then: + function: 'IPA117OneOfPropertyDescriptionsMustMatch' diff --git a/tools/spectral/ipa/rulesets/IPA-131.yaml b/tools/spectral/ipa/rulesets/IPA-131.yaml new file mode 100644 index 00000000000..92eccf1720a --- /dev/null +++ b/tools/spectral/ipa/rulesets/IPA-131.yaml @@ -0,0 +1,69 @@ +# IPA-131: Declarative Tooling Extensions +# https://mongodb.github.io/ipa/131 + +functions: + - IPA131ArraySemantic + - IPA131ServerComputedWhenClientOmitted + - IPA131ServerComputedImmutable + +aliases: + SchemaProperties: + - '$.components.schemas..properties' + - '$.paths..requestBody.content[?(@property.match(/json$/i))].schema..properties' + - '$.paths..responses..content[?(@property.match(/json$/i))].schema..properties' + +rules: + xgen-IPA-131-array-semantic: + description: | + The `x-xgen-array-semantic` extension must be well-formed. + + ##### Implementation details + Rule checks for the following conditions: + - Only fires when the `x-xgen-array-semantic` extension is explicitly declared on a property + - The carrying property must be `type: array` + - The extension value must be `list` or `set` + - Properties with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-131-array-semantic' + severity: error + resolved: false + given: + - '#SchemaProperties' + then: + field: '@key' + function: 'IPA131ArraySemantic' + xgen-IPA-131-server-computed-when-client-omitted: + description: | + The `x-xgen-server-computed-when-client-omitted` extension must be well-formed. + + ##### Implementation details + Rule checks for the following conditions: + - Only fires when the `x-xgen-server-computed-when-client-omitted` extension is explicitly declared on a property + - The extension value must be a boolean + - The carrying property must not appear in the schema's `required` list + - Properties with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-131-server-computed-when-client-omitted' + severity: error + resolved: false + given: + - '#SchemaProperties' + then: + field: '@key' + function: 'IPA131ServerComputedWhenClientOmitted' + xgen-IPA-131-server-computed-immutable: + description: | + The `x-xgen-server-computed-immutable` extension must be well-formed. + + ##### Implementation details + Rule checks for the following conditions: + - Only fires when the `x-xgen-server-computed-immutable` extension is explicitly declared on a property + - The extension value must be a boolean + - The carrying property must also be marked as `readOnly: true` + - Properties with `x-xgen-IPA-exception` for this rule are excluded from validation + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-131-server-computed-immutable' + severity: error + resolved: false + given: + - '#SchemaProperties' + then: + field: '@key' + function: 'IPA131ServerComputedImmutable' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 48aeb59ba0d..c2b7468db32 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -81,6 +81,25 @@ Rule checks for the following conditions: +### IPA-103 + +Rules are based on [https://mongodb.github.io/ipa/103](https://mongodb.github.io/ipa/103). + +#### xgen-IPA-103-response-body-root-shape + + ![error](https://img.shields.io/badge/error-red) +Response bodies must define a well-defined root shape: a JSON object with named properties, or a discriminated `oneOf` whose variants are all objects. + +##### Implementation details +Rule checks for the following conditions: + - Applies to JSON response bodies that define a schema + - The resolved schema must be `type: object` with at least one named property, or a `oneOf` whose variants are all `type: object` + - Collection envelopes per IPA-110 (`results`, `links`, `totalCount`) satisfy the rule, as they are objects with named properties + - Rejects `type: array`, primitive types, and objects whose only keys are `additionalProperties` + - Responses with `x-xgen-IPA-exception` for this rule are excluded from validation + + + ### IPA-104 Rules are based on [https://mongodb.github.io/ipa/104](https://mongodb.github.io/ipa/104). @@ -1256,6 +1275,18 @@ This rule includes a configuration option: - `preferredWords`: List of words that the operation summary should use for single items, defaults to `['one']`. Only used for error messages - `forbiddenWords`: List of words (lowercase) that the operation summary should not use, defaults to `['a', 'specified']` +#### xgen-IPA-117-oneof-property-descriptions-must-match + + ![warn](https://img.shields.io/badge/warning-yellow) +Shared properties across `oneOf` variants should use consistent descriptions. + +##### Implementation details +Rule checks for the following conditions: + - Applies to every `oneOf` schema in the spec + - For every property name shared across two or more variants, the property's `description` must be byte-equal across variants + - Some divergence may be intentional, so this rule surfaces cases for review without blocking + - Schemas with `x-xgen-IPA-exception` for this rule are excluded from validation + ### IPA-118 @@ -1445,6 +1476,48 @@ This rule includes two configuration options: +### IPA-131 + +Rules are based on [https://mongodb.github.io/ipa/131](https://mongodb.github.io/ipa/131). + +#### xgen-IPA-131-array-semantic + + ![error](https://img.shields.io/badge/error-red) +The `x-xgen-array-semantic` extension must be well-formed. + +##### Implementation details +Rule checks for the following conditions: + - Only fires when the `x-xgen-array-semantic` extension is explicitly declared on a property + - The carrying property must be `type: array` + - The extension value must be `list` or `set` + - Properties with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-131-server-computed-when-client-omitted + + ![error](https://img.shields.io/badge/error-red) +The `x-xgen-server-computed-when-client-omitted` extension must be well-formed. + +##### Implementation details +Rule checks for the following conditions: + - Only fires when the `x-xgen-server-computed-when-client-omitted` extension is explicitly declared on a property + - The extension value must be a boolean + - The carrying property must not appear in the schema's `required` list + - Properties with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-131-server-computed-immutable + + ![error](https://img.shields.io/badge/error-red) +The `x-xgen-server-computed-immutable` extension must be well-formed. + +##### Implementation details +Rule checks for the following conditions: + - Only fires when the `x-xgen-server-computed-immutable` extension is explicitly declared on a property + - The extension value must be a boolean + - The carrying property must also be marked as `readOnly: true` + - Properties with `x-xgen-IPA-exception` for this rule are excluded from validation + + + ### IPA-132 Rules are based on [https://mongodb.github.io/ipa/132](https://mongodb.github.io/ipa/132). diff --git a/tools/spectral/ipa/rulesets/functions/IPA103ResponseBodyRootShape.js b/tools/spectral/ipa/rulesets/functions/IPA103ResponseBodyRootShape.js new file mode 100644 index 00000000000..cab4bb3eda2 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA103ResponseBodyRootShape.js @@ -0,0 +1,51 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { resolveObject } from './utils/componentUtils.js'; + +const ERROR_MESSAGE = + 'Response bodies must be a JSON object with named properties, or a oneOf whose variants are all objects.'; + +/** + * Response body root shape check for IPA-103. + * + * The rule verifies that every JSON response body resolves to an object with named properties, or a + * `oneOf` whose variants are all objects. It rejects arrays, primitive types and objects whose only + * keys are `additionalProperties`. + * + * @param {string} input the response content media type (from `field: '@key'`) + * @param _ unused options + * @param {object} context the context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.resolved; + const contentPerMediaType = resolveObject(oas, path); + + // Only applies to JSON response bodies that define a schema + if (!input.endsWith('json') || !contentPerMediaType || !contentPerMediaType.schema) { + return; + } + + const errors = checkViolationsAndReturnErrors(contentPerMediaType.schema, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, contentPerMediaType, path); +}; + +function checkViolationsAndReturnErrors(schema, path, ruleName) { + try { + if (isValidRootShape(schema)) { + return []; + } + return [{ path, message: ERROR_MESSAGE }]; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} + +function isValidRootShape(schema) { + // A discriminated oneOf whose variants are all objects + if (Array.isArray(schema.oneOf)) { + return schema.oneOf.length > 0 && schema.oneOf.every((variant) => variant && variant.type === 'object'); + } + // A JSON object with a fixed set of named properties. This rejects type: array, primitive types, + // and objects whose only keys are additionalProperties. + return schema.type === 'object' && schema.properties !== undefined && Object.keys(schema.properties).length > 0; +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA117OneOfPropertyDescriptionsMustMatch.js b/tools/spectral/ipa/rulesets/functions/IPA117OneOfPropertyDescriptionsMustMatch.js new file mode 100644 index 00000000000..2ff61870618 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA117OneOfPropertyDescriptionsMustMatch.js @@ -0,0 +1,59 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { resolveObject } from './utils/componentUtils.js'; + +/** + * Polymorphic property-description divergence check for IPA-117. + * + * For every property name shared across two or more `oneOf` variants, the property's `description` + * must be byte-equal across variants. + * + * @param {object[]} input the `oneOf` array (from `given: '$..oneOf'`) + * @param _ unused options + * @param {object} context the context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.resolved; + const schemaPath = path.slice(0, path.length - 1); + const parentSchema = resolveObject(oas, schemaPath); + + if (!Array.isArray(input) || input.length < 2) { + return; + } + + const errors = checkViolationsAndReturnErrors(input, schemaPath, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, parentSchema, schemaPath); +}; + +function checkViolationsAndReturnErrors(variants, path, ruleName) { + try { + // Build a property name -> set of descriptions map across all variants + const descriptionsByProperty = new Map(); + variants.forEach((variant) => { + const properties = variant?.properties; + if (!properties) { + return; + } + Object.keys(properties).forEach((propertyName) => { + if (!descriptionsByProperty.has(propertyName)) { + descriptionsByProperty.set(propertyName, []); + } + descriptionsByProperty.get(propertyName).push(properties[propertyName].description); + }); + }); + + const errors = []; + descriptionsByProperty.forEach((descriptions, propertyName) => { + // Only shared properties (present in two or more variants) are checked + if (descriptions.length > 1 && !descriptions.every((description) => description === descriptions[0])) { + errors.push({ + path, + message: `Property '${propertyName}' has diverging descriptions across oneOf variants. Shared properties must use the same description.`, + }); + } + }); + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA131ArraySemantic.js b/tools/spectral/ipa/rulesets/functions/IPA131ArraySemantic.js new file mode 100644 index 00000000000..11442552485 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA131ArraySemantic.js @@ -0,0 +1,46 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { ARRAY_SEMANTIC_EXTENSION } from './utils/extensions.js'; + +const ALLOWED_VALUES = ['list', 'set']; +const ERROR_MESSAGE = `The ${ARRAY_SEMANTIC_EXTENSION} extension must be set to one of ${ALLOWED_VALUES.join(', ')}, and its carrying property must be type: array.`; + +/** + * IPA-131 structural validation for the x-xgen-array-semantic extension. + * + * When the extension is present, the carrying property must be `type: array` and the extension value + * must be `list` or `set`. + * + * @param {string} input the property name (from `field: '@key'`) + * @param _ unused options + * @param {object} context the context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.unresolved; + const property = resolveObject(oas, path); + + // Skip schema references ($ref), validated separately to prevent duplicate violations + if (!property) { + return; + } + + // The rule only fires when the extension is explicitly declared + if (!Object.keys(property).includes(ARRAY_SEMANTIC_EXTENSION)) { + return; + } + + const errors = checkViolationsAndReturnErrors(property, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, property, path); +}; + +function checkViolationsAndReturnErrors(property, path, ruleName) { + try { + if (property.type !== 'array' || !ALLOWED_VALUES.includes(property[ARRAY_SEMANTIC_EXTENSION])) { + return [{ path, message: ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA131ServerComputedImmutable.js b/tools/spectral/ipa/rulesets/functions/IPA131ServerComputedImmutable.js new file mode 100644 index 00000000000..55698b30f22 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA131ServerComputedImmutable.js @@ -0,0 +1,50 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { SERVER_COMPUTED_IMMUTABLE_EXTENSION } from './utils/extensions.js'; + +const VALUE_ERROR_MESSAGE = `The ${SERVER_COMPUTED_IMMUTABLE_EXTENSION} extension value must be a boolean.`; +const READ_ONLY_ERROR_MESSAGE = `A property with the ${SERVER_COMPUTED_IMMUTABLE_EXTENSION} extension must also be marked as readOnly: true.`; + +/** + * IPA-131 structural validation for the x-xgen-server-computed-immutable extension. + * + * When the extension is present, its value must be a boolean and the carrying property must also be + * marked as `readOnly: true`. + * + * @param {string} input the property name (from `field: '@key'`) + * @param _ unused options + * @param {object} context the context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.unresolved; + const property = resolveObject(oas, path); + + // Skip schema references ($ref), validated separately to prevent duplicate violations + if (!property) { + return; + } + + // The rule only fires when the extension is explicitly declared + if (!Object.keys(property).includes(SERVER_COMPUTED_IMMUTABLE_EXTENSION)) { + return; + } + + const errors = checkViolationsAndReturnErrors(property, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, property, path); +}; + +function checkViolationsAndReturnErrors(property, path, ruleName) { + try { + const errors = []; + if (typeof property[SERVER_COMPUTED_IMMUTABLE_EXTENSION] !== 'boolean') { + errors.push({ path, message: VALUE_ERROR_MESSAGE }); + } + if (property.readOnly !== true) { + errors.push({ path, message: READ_ONLY_ERROR_MESSAGE }); + } + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA131ServerComputedWhenClientOmitted.js b/tools/spectral/ipa/rulesets/functions/IPA131ServerComputedWhenClientOmitted.js new file mode 100644 index 00000000000..95595edef19 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA131ServerComputedWhenClientOmitted.js @@ -0,0 +1,51 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { isRequiredProperty } from './utils/schemaUtils.js'; +import { SERVER_COMPUTED_WHEN_CLIENT_OMITTED_EXTENSION } from './utils/extensions.js'; + +const VALUE_ERROR_MESSAGE = `The ${SERVER_COMPUTED_WHEN_CLIENT_OMITTED_EXTENSION} extension value must be a boolean.`; +const REQUIRED_ERROR_MESSAGE = `A property with the ${SERVER_COMPUTED_WHEN_CLIENT_OMITTED_EXTENSION} extension must not appear in the schema's required list.`; + +/** + * IPA-131 structural validation for the x-xgen-server-computed-when-client-omitted extension. + * + * When the extension is present, its value must be a boolean and the carrying property must not appear + * in the enclosing schema's `required` list. + * + * @param {string} input the property name (from `field: '@key'`) + * @param _ unused options + * @param {object} context the context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.unresolved; + const property = resolveObject(oas, path); + + // Skip schema references ($ref), validated separately to prevent duplicate violations + if (!property) { + return; + } + + // The rule only fires when the extension is explicitly declared + if (!Object.keys(property).includes(SERVER_COMPUTED_WHEN_CLIENT_OMITTED_EXTENSION)) { + return; + } + + const errors = checkViolationsAndReturnErrors(property, oas, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, property, path); +}; + +function checkViolationsAndReturnErrors(property, oas, path, ruleName) { + try { + const errors = []; + if (typeof property[SERVER_COMPUTED_WHEN_CLIENT_OMITTED_EXTENSION] !== 'boolean') { + errors.push({ path, message: VALUE_ERROR_MESSAGE }); + } + if (isRequiredProperty(oas, path)) { + errors.push({ path, message: REQUIRED_ERROR_MESSAGE }); + } + return errors; + } 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 c7f551d450c..d837e859510 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/extensions.js +++ b/tools/spectral/ipa/rulesets/functions/utils/extensions.js @@ -1,6 +1,8 @@ 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'; +export const ARRAY_SEMANTIC_EXTENSION = 'x-xgen-array-semantic'; +export const SERVER_COMPUTED_IMMUTABLE_EXTENSION = 'x-xgen-server-computed-immutable'; /** * Checks if the object has the extension "x-xgen-server-computed-when-client-omitted", marking a field