diff --git a/tools/spectral/ipa/__tests__/IPA113ResetMethodNotOnReadonlySingleton.test.js b/tools/spectral/ipa/__tests__/IPA113ResetMethodNotOnReadonlySingleton.test.js index 49a40c6c32..e0414069c9 100644 --- a/tools/spectral/ipa/__tests__/IPA113ResetMethodNotOnReadonlySingleton.test.js +++ b/tools/spectral/ipa/__tests__/IPA113ResetMethodNotOnReadonlySingleton.test.js @@ -32,6 +32,47 @@ testRule('xgen-IPA-113-reset-method-not-on-readonly-singleton', [ }, errors: [], }, + { + name: 'valid reset when base path returns list response shape', + document: { + paths: { + '/resource/{exampleId}/listSingleton': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + results: { + type: 'array', + readOnly: true, + items: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + status: { type: 'string', readOnly: true }, + }, + }, + }, + totalCount: { type: 'integer', readOnly: true }, + }, + required: ['results'], + }, + }, + }, + }, + }, + }, + }, + '/resource/{exampleId}/listSingleton:reset': { + post: {}, + }, + }, + }, + errors: [], + }, { name: 'invalid reset on read-only singleton', document: { diff --git a/tools/spectral/ipa/__tests__/IPA113SingletonHasUpdateMethod.test.js b/tools/spectral/ipa/__tests__/IPA113SingletonHasUpdateMethod.test.js index 703e702043..9ce598006f 100644 --- a/tools/spectral/ipa/__tests__/IPA113SingletonHasUpdateMethod.test.js +++ b/tools/spectral/ipa/__tests__/IPA113SingletonHasUpdateMethod.test.js @@ -95,7 +95,164 @@ testRule('xgen-IPA-113-singleton-should-have-update-method', [ errors: [], }, { - name: 'read-only singleton with List response', + name: 'read-only singleton with referenced nested schema does not require update method', + document: { + paths: { + '/resource/{exampleId}/readOnlySingleton': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ReadOnlySingleton', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ReadOnlySingleton: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + metadata: { + $ref: '#/components/schemas/ReadOnlyMetadata', + }, + }, + }, + ReadOnlyMetadata: { + type: 'object', + properties: { + createdAt: { type: 'string', readOnly: true }, + updatedAt: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'singleton with writable property in referenced nested schema requires update method', + document: { + paths: { + '/resource/{exampleId}/writableSingleton': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/WritableSingleton', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + WritableSingleton: { + type: 'object', + properties: { + status: { type: 'string', readOnly: true }, + metadata: { + $ref: '#/components/schemas/WritableMetadata', + }, + }, + }, + WritableMetadata: { + type: 'object', + properties: { + createdAt: { type: 'string', readOnly: true }, + displayName: { type: 'string' }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-113-singleton-should-have-update-method', + message: + 'Singleton resources should define the Update method. If this is not a singleton resource, please implement all CRUDL methods.', + path: ['paths', '/resource/{exampleId}/writableSingleton'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + ...['allOf', 'anyOf', 'oneOf'].flatMap((composition) => + [ + { description: 'read-only branches', withProperties: false, writable: false }, + { description: 'a writable branch', withProperties: false, writable: true }, + { description: 'read-only branches alongside properties', withProperties: true, writable: false }, + { description: 'a writable branch alongside read-only properties', withProperties: true, writable: true }, + ].map(({ description, withProperties, writable }) => ({ + name: `singleton with referenced nested ${composition} containing ${description}`, + document: { + paths: { + '/resource/{exampleId}/singleton': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Singleton' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Singleton: { + type: 'object', + properties: { + metadata: { $ref: '#/components/schemas/Metadata' }, + }, + }, + Metadata: { + ...(withProperties ? { properties: { id: { type: 'string', readOnly: true } } } : {}), + [composition]: [ + { $ref: '#/components/schemas/ReadOnlyMetadataPart' }, + { $ref: '#/components/schemas/OtherMetadataPart' }, + ], + }, + ReadOnlyMetadataPart: { + type: 'object', + properties: { createdAt: { type: 'string', readOnly: true } }, + }, + OtherMetadataPart: { + type: 'object', + properties: { displayName: { type: 'string', readOnly: !writable } }, + }, + }, + }, + }, + errors: writable + ? [ + { + code: 'xgen-IPA-113-singleton-should-have-update-method', + message: + 'Singleton resources should define the Update method. If this is not a singleton resource, please implement all CRUDL methods.', + path: ['paths', '/resource/{exampleId}/singleton'], + severity: DiagnosticSeverity.Error, + }, + ] + : [], + })) + ), + { + name: 'list response shape does not prove singleton is read-only', document: { paths: { '/resource/{exampleId}/readOnlyListSingleton': { @@ -109,6 +266,7 @@ testRule('xgen-IPA-113-singleton-should-have-update-method', [ properties: { results: { type: 'array', + readOnly: true, items: { type: 'object', properties: { @@ -118,8 +276,9 @@ testRule('xgen-IPA-113-singleton-should-have-update-method', [ }, }, }, - totalCount: { type: 'integer' }, + totalCount: { type: 'integer', readOnly: true }, }, + required: ['results'], }, }, }, @@ -129,7 +288,15 @@ testRule('xgen-IPA-113-singleton-should-have-update-method', [ }, }, }, - errors: [], + errors: [ + { + code: 'xgen-IPA-113-singleton-should-have-update-method', + message: + 'Singleton resources should define the Update method. If this is not a singleton resource, please implement all CRUDL methods.', + path: ['paths', '/resource/{exampleId}/readOnlyListSingleton'], + severity: DiagnosticSeverity.Error, + }, + ], }, { name: 'writable singleton with List response', diff --git a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js index ce8bfbeaf1..2ef2278b94 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js @@ -2,7 +2,7 @@ import testRule from './__helpers__/testRule'; import { DiagnosticSeverity } from '@stoplight/types'; const READ_ONLY_SCHEMA_ERROR_MESSAGE = - 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.'; + 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true or contain only read-only properties.'; const readOnlyGet = { responses: { diff --git a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js index f92a809fb7..ba86bae144 100644 --- a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js +++ b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js @@ -445,6 +445,82 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () }, expected: true, }, + { + description: 'schema with unmarked nested object containing only readOnly properties', + schema: { + type: 'object', + properties: { + metadata: { + type: 'object', + properties: { + createdBy: { type: 'string', readOnly: true }, + updatedBy: { type: 'string', readOnly: true }, + }, + }, + }, + }, + expected: true, + }, + { + description: 'schema with nested object containing a writable property', + schema: { + type: 'object', + properties: { + metadata: { + type: 'object', + properties: { + createdBy: { type: 'string', readOnly: true }, + displayName: { type: 'string' }, + }, + }, + }, + }, + expected: false, + }, + { + description: 'schema with nested array containing only readOnly properties', + schema: { + type: 'object', + properties: { + entries: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + status: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }, + expected: true, + }, + { + description: 'schema with nested allOf containing only readOnly properties', + schema: { + type: 'object', + properties: { + metadata: { + allOf: [ + { + type: 'object', + properties: { + createdBy: { type: 'string', readOnly: true }, + }, + }, + { + type: 'object', + properties: { + updatedBy: { type: 'string', readOnly: true }, + }, + }, + ], + }, + }, + }, + expected: true, + }, { description: 'schema with array items all readOnly', schema: { @@ -490,28 +566,27 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () expected: false, }, { - description: 'singleton List response with all readOnly items', + description: 'schema with readOnly array property containing writable item schema', schema: { type: 'object', properties: { - results: { + auditEvents: { type: 'array', + readOnly: true, items: { type: 'object', properties: { id: { type: 'string', readOnly: true }, - name: { type: 'string', readOnly: true }, - status: { type: 'string', readOnly: true }, + message: { type: 'string' }, }, }, }, - totalCount: { type: 'integer' }, }, }, expected: true, }, { - description: 'singleton List response with some non-readOnly items', + description: 'list response shape with writable metadata is not treated specially', schema: { type: 'object', properties: { @@ -538,6 +613,90 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () expect(allPropertiesAreReadOnly(testCase.schema)).toEqual(testCase.expected); }); }); + + describe.each(['allOf', 'anyOf', 'oneOf'])('%s compositions', (composition) => { + const readOnlySchema = { + type: 'object', + properties: { id: { type: 'string', readOnly: true } }, + }; + const writableSchema = { + type: 'object', + properties: { displayName: { type: 'string' } }, + }; + + it.each([ + { + description: 'all branches are read-only, including a shared schema', + schema: { [composition]: [readOnlySchema, readOnlySchema] }, + expected: true, + }, + { + description: 'a writable branch follows a read-only branch', + schema: { [composition]: [readOnlySchema, writableSchema] }, + expected: false, + }, + { + description: 'a writable branch precedes a read-only branch', + schema: { [composition]: [writableSchema, readOnlySchema] }, + expected: false, + }, + { + description: 'read-only properties coexist with read-only branches', + schema: { ...readOnlySchema, [composition]: [readOnlySchema] }, + expected: true, + }, + { + description: 'read-only properties coexist with a writable branch', + schema: { ...readOnlySchema, [composition]: [writableSchema] }, + expected: false, + }, + { + description: 'writable properties coexist with a read-only branch', + schema: { ...writableSchema, [composition]: [readOnlySchema] }, + expected: false, + }, + { + description: 'read-only array items coexist with a writable branch', + schema: { type: 'array', items: readOnlySchema, [composition]: [{ type: 'array', items: writableSchema }] }, + expected: false, + }, + { + description: 'the composition is empty', + schema: { [composition]: [] }, + expected: false, + }, + ])('returns $expected when $description', ({ schema, expected }) => { + expect(allPropertiesAreReadOnly({ type: 'object', properties: { metadata: schema } })).toEqual(expected); + }); + + it('returns false for a circular branch after a read-only branch', () => { + const schema = { [composition]: [readOnlySchema] }; + schema[composition].push(schema); + + expect(allPropertiesAreReadOnly(schema)).toEqual(false); + }); + }); + + it('checks sibling composition keywords even when allOf is read-only', () => { + const schema = { + allOf: [{ type: 'object', properties: { id: { type: 'string', readOnly: true } } }], + anyOf: [{ type: 'object', properties: { displayName: { type: 'string' } } }], + }; + + expect(allPropertiesAreReadOnly(schema)).toEqual(false); + }); + + it('returns false for an unmarked circular schema', () => { + const schema = { + type: 'object', + properties: { + id: { type: 'string', readOnly: true }, + }, + }; + schema.properties.parent = schema; + + expect(allPropertiesAreReadOnly(schema)).toEqual(false); + }); }); describe('isReadOnlyResource', () => { @@ -567,6 +726,33 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () resourcePathItems: readOnlySingleton, expected: true, }, + { + description: 'singleton with list response shape', + resourcePathItems: { + '/resource/{id}/listSingleton': { + get: { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + links: { type: 'array', readOnly: true, items: { type: 'object' } }, + results: { type: 'array', readOnly: true, items: { type: 'object' } }, + totalCount: { type: 'integer', readOnly: true }, + }, + required: ['results'], + }, + }, + }, + }, + }, + }, + }, + }, + expected: false, + }, { description: 'resource with xgen-IPA-104-resource-has-GET exception', resourcePathItems: { diff --git a/tools/spectral/ipa/rulesets/IPA-106.yaml b/tools/spectral/ipa/rulesets/IPA-106.yaml index f3defb6fc2..aa8ca9581a 100644 --- a/tools/spectral/ipa/rulesets/IPA-106.yaml +++ b/tools/spectral/ipa/rulesets/IPA-106.yaml @@ -122,7 +122,7 @@ rules: ##### Implementation details Rule checks for the following conditions: - Applies to POST methods on resource collection paths - - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Checks if the resource is a read-only resource (all properties in the GET response are marked as readOnly or contain only read-only properties) - If a resource does not have a standard GET method, it is not considered read-only (cannot determine the resource schema) - Fails if a Create method is defined on a read-only resource - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation diff --git a/tools/spectral/ipa/rulesets/IPA-107.yaml b/tools/spectral/ipa/rulesets/IPA-107.yaml index 23f39556b2..104cad848a 100644 --- a/tools/spectral/ipa/rulesets/IPA-107.yaml +++ b/tools/spectral/ipa/rulesets/IPA-107.yaml @@ -122,7 +122,7 @@ rules: ##### Implementation details Rule checks for the following conditions: - Applies to PUT/PATCH methods on all resource paths - - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Checks if the resource is a read-only resource (all properties in the GET response are marked as readOnly or contain only read-only properties) - If a resource does not have a standard GET method, it is not considered read-only (cannot determine the resource schema) - Fails if an Update method is defined on a read-only resource - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation diff --git a/tools/spectral/ipa/rulesets/IPA-108.yaml b/tools/spectral/ipa/rulesets/IPA-108.yaml index 66c677e6e6..3d23c00a1e 100644 --- a/tools/spectral/ipa/rulesets/IPA-108.yaml +++ b/tools/spectral/ipa/rulesets/IPA-108.yaml @@ -62,7 +62,7 @@ rules: ##### Implementation details Rule checks for the following conditions: - Applies to DELETE methods on single resource paths and singleton resources - - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Checks if the resource is a read-only resource (all properties in the GET response are marked as readOnly or contain only read-only properties) - If a resource does not have a standard GET method, it is not considered read-only (cannot determine the resource schema) - Fails if a Delete method is defined on a read-only resource - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation diff --git a/tools/spectral/ipa/rulesets/IPA-113.yaml b/tools/spectral/ipa/rulesets/IPA-113.yaml index eaa966aaa7..a100d5fb2b 100644 --- a/tools/spectral/ipa/rulesets/IPA-113.yaml +++ b/tools/spectral/ipa/rulesets/IPA-113.yaml @@ -50,7 +50,7 @@ rules: ##### Implementation details Rule checks for the following conditions: - Applies only to singleton resources - - Excludes read-only singleton resources (where all properties in the GET response schema are marked as readOnly; for List responses, all properties in the items schema must be readOnly) + - Excludes read-only singleton resources (where all properties in the GET response schema are marked as readOnly or contain only read-only properties; for List responses, all properties in the items schema must be readOnly) - Checks that the resource has the PUT and/or PATCH methods defined message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-113-singleton-should-have-update-method' severity: error @@ -142,7 +142,7 @@ rules: - Applies only to paths ending with :reset - Verifies that the parent singleton resource is not read-only - Uses existing isReadOnlyResource() helper function - - Fails if the singleton resource has all properties marked as readOnly: true + - Fails if all properties of the singleton resource are marked as readOnly or contain only read-only properties message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-113-reset-method-not-on-readonly-singleton' severity: error given: '$.paths[*]' diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml index 9261e1f93e..c7deaf3e90 100644 --- a/tools/spectral/ipa/rulesets/IPA-132.yaml +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -52,7 +52,7 @@ rules: xgen-IPA-132-operation-must-be-a-read-only-resource: description: | Operations endpoints are read-only. They may only define the get method, and all properties - of the Operation resource must be readOnly. + of the Operation resource must be read-only. ##### Implementation details Rule checks for the following conditions: @@ -62,7 +62,7 @@ rules: - The path item must not define any HTTP method other than `get` - On the single Operation endpoint (`.../operations/{operationId}`), where the Get method is defined, all properties of every 2xx response schema of the `get` method must be - marked as `readOnly: true` + marked as `readOnly: true` or contain only read-only properties - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operation-must-be-a-read-only-resource' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 48aeb59ba0..c1aff88f3f 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -365,7 +365,7 @@ Read-only resources must not define the Create method. ##### Implementation details Rule checks for the following conditions: - Applies to POST methods on resource collection paths - - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Checks if the resource is a read-only resource (all properties in the GET response are marked as readOnly or contain only read-only properties) - If a resource does not have a standard GET method, it is not considered read-only (cannot determine the resource schema) - Fails if a Create method is defined on a read-only resource - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation @@ -484,7 +484,7 @@ Read-only resources must not define the Update method. ##### Implementation details Rule checks for the following conditions: - Applies to PUT/PATCH methods on all resource paths - - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Checks if the resource is a read-only resource (all properties in the GET response are marked as readOnly or contain only read-only properties) - If a resource does not have a standard GET method, it is not considered read-only (cannot determine the resource schema) - Fails if an Update method is defined on a read-only resource - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation @@ -577,7 +577,7 @@ Read-only resources must not define the Delete method. ##### Implementation details Rule checks for the following conditions: - Applies to DELETE methods on single resource paths and singleton resources - - Checks if the resource is a read-only resource (all properties in GET response have readOnly:true) + - Checks if the resource is a read-only resource (all properties in the GET response are marked as readOnly or contain only read-only properties) - If a resource does not have a standard GET method, it is not considered read-only (cannot determine the resource schema) - Fails if a Delete method is defined on a read-only resource - Operation objects with `x-xgen-IPA-exception` for this rule are excluded from validation @@ -891,7 +891,7 @@ Singleton resources should define the Update method. Validation for the presence ##### Implementation details Rule checks for the following conditions: - Applies only to singleton resources - - Excludes read-only singleton resources (where all properties in the GET response schema are marked as readOnly; for List responses, all properties in the items schema must be readOnly) + - Excludes read-only singleton resources (where all properties in the GET response schema are marked as readOnly or contain only read-only properties; for List responses, all properties in the items schema must be readOnly) - Checks that the resource has the PUT and/or PATCH methods defined #### xgen-IPA-113-reset-method-must-use-POST @@ -964,7 +964,7 @@ Rule checks for the following conditions: - Applies only to paths ending with :reset - Verifies that the parent singleton resource is not read-only - Uses existing isReadOnlyResource() helper function - - Fails if the singleton resource has all properties marked as readOnly: true + - Fails if all properties of the singleton resource are marked as readOnly or contain only read-only properties #### xgen-IPA-113-reset-method-valid-operation-id @@ -1469,7 +1469,7 @@ Rule checks for the following conditions: ![warn](https://img.shields.io/badge/warning-yellow) Operations endpoints are read-only. They may only define the get method, and all properties -of the Operation resource must be readOnly. +of the Operation resource must be read-only. ##### Implementation details Rule checks for the following conditions: @@ -1479,7 +1479,7 @@ Rule checks for the following conditions: - The path item must not define any HTTP method other than `get` - On the single Operation endpoint (`.../operations/{operationId}`), where the Get method is defined, all properties of every 2xx response schema of the `get` method must be - marked as `readOnly: true` + marked as `readOnly: true` or contain only read-only properties - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation #### xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js index 5a7d789a30..fa64d8d093 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js @@ -5,7 +5,7 @@ import { isOperationsPath, isSingleOperationPath } from './utils/longRunningOper const VALID_METHOD = 'get'; const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; const READ_ONLY_SCHEMA_ERROR_MESSAGE = - 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.'; + 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true or contain only read-only properties.'; /** * Checks that an Operations endpoint defined by IPA-132 is a read-only resource: its path items diff --git a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js index 607c76bf91..0024fe6486 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js +++ b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js @@ -242,51 +242,59 @@ export function removePrefix(path) { } /** - * Checks if all properties in a schema have readOnly: true. + * Checks if all properties in a schema are read-only. * * @param {Object} schema - The schema to check + * @param {Set} visiting - Schemas in the current traversal path * @returns {boolean} true if all properties are readOnly, false otherwise */ -export function allPropertiesAreReadOnly(schema) { +export function allPropertiesAreReadOnly(schema, visiting = new Set()) { if (!schema || typeof schema !== 'object') { return false; } - if (schema.properties) { - if (schema.properties.results && schema.properties.results.type === 'array' && schema.properties.results.items) { - return allPropertiesAreReadOnly(schema.properties.results.items); - } - - for (const [, propSchema] of Object.entries(schema.properties)) { - if (propSchema.readOnly !== true) { + if (visiting.has(schema)) { + return false; + } + visiting.add(schema); + + try { + // Compositions can coexist with properties or items; none may hide writable fields in the others. + let hasReadOnlyComposition = false; + for (const composition of ['allOf', 'anyOf', 'oneOf']) { + const branches = schema[composition]; + if (!Array.isArray(branches)) { + continue; + } + if (branches.length === 0 || !branches.every((subSchema) => isSchemaReadOnly(subSchema, visiting))) { return false; } + hasReadOnlyComposition = true; } - return Object.keys(schema.properties).length > 0; - } - - if (schema.items) { - return allPropertiesAreReadOnly(schema.items); - } - if (Array.isArray(schema.allOf)) { - return schema.allOf.every((subSchema) => allPropertiesAreReadOnly(subSchema)); - } + if (schema.properties) { + const properties = Object.values(schema.properties); + return properties.length > 0 && properties.every((property) => isSchemaReadOnly(property, visiting)); + } - if (Array.isArray(schema.anyOf)) { - return schema.anyOf.some((subSchema) => allPropertiesAreReadOnly(subSchema)); - } + if (schema.items) { + return isSchemaReadOnly(schema.items, visiting); + } - if (Array.isArray(schema.oneOf)) { - return schema.oneOf.some((subSchema) => allPropertiesAreReadOnly(subSchema)); + return hasReadOnlyComposition; + } finally { + visiting.delete(schema); } +} - return false; +function isSchemaReadOnly(schema, visiting) { + return schema?.readOnly === true || allPropertiesAreReadOnly(schema, visiting); } /** * Checks if a resource is a read-only resource. - * A read-only resource has all properties in its GET response schema marked as readOnly: true. + * A read-only resource has all properties in its GET response schema marked as readOnly: true + * or composed only of read-only properties. * * @param {Object} resourcePathItems - All path items for the resource to be evaluated * @returns {boolean} true if the resource is read-only, false otherwise @@ -351,6 +359,12 @@ export function isReadOnlyResource(resourcePathItems) { continue; } + // List responses are containers around resources, not resource schemas themselves. + // They cannot prove that a singleton or resource is read-only. + if (isListResponseSchema(mediaTypeObj.schema)) { + return false; + } + if (!allPropertiesAreReadOnly(mediaTypeObj.schema)) { return false; } @@ -358,3 +372,13 @@ export function isReadOnlyResource(resourcePathItems) { return true; } + +function isListResponseSchema(schema) { + const properties = schema?.properties; + const results = properties?.results; + return ( + results?.type === 'array' && + Boolean(results.items) && + Boolean(properties.links || properties.totalCount || schema.required?.includes('results')) + ); +}