From b9fea7c8a503dc0d9cefd7697c86c87d3c06e466 Mon Sep 17 00:00:00 2001 From: Carsten Kirschner Date: Mon, 9 Nov 2026 12:33:04 +0100 Subject: [PATCH] Keep title, description and deprecation of a multi-type property on the property, show constraints only on the type option they apply to. Co-Authored-By: Claude Opus 5.5 --- mocks/multi-type-variants-schema.json | 179 +++++++++++++++++++++++++- src/utils/schema-utils.js | 27 ++-- tests/schema-utils.test.js | 62 ++++++++- 3 files changed, 252 insertions(+), 16 deletions(-) diff --git a/mocks/multi-type-variants-schema.json b/mocks/multi-type-variants-schema.json index fd068c74..74eb8e44 100644 --- a/mocks/multi-type-variants-schema.json +++ b/mocks/multi-type-variants-schema.json @@ -3,7 +3,7 @@ "info": { "title": "Multi-type and xxx-of variants", "version": "1.0", - "description": "Every way a `patternProperties` object can appear as a child: multi-type (`type: [object, null]`), oneOf, anyOf, allOf, typed and untyped. Each must show the `` key with the Translation fields." + "description": "Every way a `patternProperties` object can appear as a child: multi-type (`type: [object, null]`), oneOf, anyOf, allOf, typed and untyped. Each must show the `` key with the Translation fields. The second operation has, per variant, a title, description, `deprecated` and constraints for every type (array, integer, string). Title, description and strike-through belong on the property; each constraint only on the option of the type it applies to." }, "paths": { "/pattern-properties": { @@ -155,6 +155,183 @@ } } } + }, + "/descriptions-and-constraints": { + "post": { + "summary": "Descriptions and constraints of multi-type properties", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "simple": { + "title": "Simple property", + "description": "Simple property: description of the property.", + "deprecated": true, + "uniqueItems": true, + "minItems": 1, + "maxItems": 5, + "minimum": 0, + "maximum": 10, + "minLength": 2, + "type": [ + "integer", + "string", + "null" + ] + }, + "array": { + "title": "Array property", + "description": "Array property: description of the property.", + "deprecated": true, + "uniqueItems": true, + "minItems": 1, + "maxItems": 5, + "minimum": 0, + "maximum": 10, + "minLength": 2, + "type": [ + "array", + "integer", + "null" + ], + "items": { + "type": "object", + "description": "Item description.", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "label": { + "type": "string", + "maxLength": 20 + } + } + } + }, + "simpleObject": { + "title": "Simple object", + "description": "Simple object: description of the property.", + "deprecated": true, + "uniqueItems": true, + "minItems": 1, + "maxItems": 5, + "minimum": 0, + "maximum": 10, + "minLength": 2, + "type": [ + "object", + "integer", + "null" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "integer", + "maximum": 99 + } + } + }, + "complexObject": { + "title": "Complex object without pattern", + "description": "Complex object without pattern: description of the property.", + "deprecated": true, + "uniqueItems": true, + "minItems": 1, + "maxItems": 5, + "minimum": 0, + "maximum": 10, + "minLength": 2, + "type": [ + "object", + "string", + "null" + ], + "properties": { + "owner": { + "$ref": "#/components/schemas/Translation" + }, + "tags": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "nested": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 0 + } + } + } + } + }, + "patternObject": { + "title": "Complex object with pattern", + "description": "Complex object with pattern: description of the property.", + "deprecated": true, + "uniqueItems": true, + "minItems": 1, + "maxItems": 5, + "minimum": 0, + "maximum": 10, + "minLength": 2, + "type": [ + "object", + "integer", + "null" + ], + "patternProperties": { + "[a-z]{2}": { + "$ref": "#/components/schemas/Translation" + } + } + }, + "explicitOneOf": { + "title": "Explicit oneOf", + "description": "Explicit oneOf: description of the property.", + "deprecated": true, + "oneOf": [ + { + "type": "object", + "description": "Object option: its own description.", + "properties": { + "name": { + "type": "string" + } + } + }, + { + "type": "integer", + "description": "Integer option: deprecated on its own.", + "deprecated": true, + "minimum": 0 + }, + { + "type": "string", + "minLength": 2 + } + ] + } + } + } + } + } + }, + "responses": { + "204": { + "description": "No content" + } + } + } } }, "components": { diff --git a/src/utils/schema-utils.js b/src/utils/schema-utils.js index a4cd21da..b98e264a 100644 --- a/src/utils/schema-utils.js +++ b/src/utils/schema-utils.js @@ -61,7 +61,7 @@ export function getTypeInfo(parameter, options = { includeNulls: false, enableEx info.allowedValues = typeof schema.const !== 'undefined' && [schema.const] || schema.items.enum || null; } - if (schema.uniqueItems) { + if (schema.uniqueItems && dataType.match(/array|\[\]/)) { info.constraints.push('Requires unique items'); } @@ -387,6 +387,7 @@ export function schemaInObjectNotation(rawSchema, options, level = 0, suffix = ' resultObj['::flags'] = { '🆁': readOnly && '🆁', '🆆': writeOnly && '🆆' }; resultObj['::title'] = schema.title || ''; resultObj['::description'] = schema.description || ''; + resultObj['::deprecated'] = schema.deprecated || false; resultObj['::metadata'] = metadata; return resultObj; } @@ -412,18 +413,23 @@ export function schemaInObjectNotation(rawSchema, options, level = 0, suffix = ' complexTypes.push(v); } }); + // Rendered as one option of several: title, description and deprecation belong to the property itself + const asPrimitiveOption = (type) => getTypeInfo({ ...subSchema, type, title: undefined, description: undefined, deprecated: undefined }, options); let multiPrimitiveTypes; if (primitiveType.length > 0) { subSchema.type = primitiveType.join('┃'); - multiPrimitiveTypes = getTypeInfo(subSchema, options); if (complexTypes.length === 0) { - return `${multiPrimitiveTypes?.html || ''}`; + return `${getTypeInfo(subSchema, options)?.html || ''}`; } + multiPrimitiveTypes = asPrimitiveOption(subSchema.type); } if (complexTypes.length > 0) { obj['::link'] = schema.title || ''; obj['::circular'] = !!schema.circularReference; obj['::type'] = 'object'; + obj['::title'] = schema.title || ''; + obj['::description'] = schema.description || ''; + obj['::deprecated'] = schema.deprecated || false; const multiTypeOptions = { '::type': 'xxx-of-option', }; @@ -433,20 +439,15 @@ export function schemaInObjectNotation(rawSchema, options, level = 0, suffix = ' if (v === 'null') { multiTypeOptions[`::OPTION~${i + 1}`] = 'NULL~|~~|~~|~~|~~|~~|~~|~~|~'; } else if ('integer, number, string, boolean,'.includes(`${v},`)) { - subSchema.type = Array.isArray(v) ? v.join('┃') : v; - const primitiveTypeInfo = getTypeInfo(subSchema, options); + const primitiveTypeInfo = asPrimitiveOption(Array.isArray(v) ? v.join('┃') : v); multiTypeOptions[`::OPTION~${i + 1}`] = primitiveTypeInfo.html; } else if (v === 'object') { // If object type iterate all the properties and create an object-type-option const objTypeOption = { - '::title': schema.title || '', - '::description': schema.description || '', '::flags': { '🆁': schema.readOnly && '🆁', '🆆': schema.writeOnly && '🆆' }, '::link': schema.title || '', '::circular': !!schema.circularReference, - '::type': 'object', - '::deprecated': schema.deprecated || false, - '::metadata': metadata + '::type': 'object' }; for (const key in schemaProperties) { if (!schema.deprecated && !schemaProperties[key].deprecated && schema.required?.includes(key)) { @@ -465,15 +466,13 @@ export function schemaInObjectNotation(rawSchema, options, level = 0, suffix = ' multiTypeOptions[`::OPTION~${i + 1}`] = objTypeOption; } else if (v === 'array') { multiTypeOptions[`::OPTION~${i + 1}`] = { - '::title': schema.title || '', - '::description': schema.description || arrayItemsSchema?.description || '', + '::description': arrayItemsSchema?.description || '', '::flags': { '🆁': schema.readOnly && '🆁', '🆆': schema.writeOnly && '🆆' }, '::link': arrayItemsSchema.title || schema.title || '', '::circular': !!schema.circularReference, '::type': 'array', // Array properties are read from the ::props object instead of reading from the keys of this object - '::props': schemaInObjectNotation(Object.assign({}, schema, arrayItemsSchema, { description: schema.description || arrayItemsSchema?.description }), options, (level + 1)), - '::deprecated': schema.deprecated || false, + '::props': schemaInObjectNotation(Object.assign({}, schema, { deprecated: undefined }, arrayItemsSchema, { description: arrayItemsSchema?.description }), options, (level + 1)), '::metadata': metadata }; } diff --git a/tests/schema-utils.test.js b/tests/schema-utils.test.js index 00a23849..d1fa5a71 100644 --- a/tests/schema-utils.test.js +++ b/tests/schema-utils.test.js @@ -2,7 +2,7 @@ import test from 'ava'; import { expect } from 'chai'; import xmlFormatter from '../src/utils/xml/xml.js'; -import { getExampleValuesFromSchema, schemaInObjectNotation } from '../src/utils/schema-utils.js'; +import { getExampleValuesFromSchema, getTypeInfo, schemaInObjectNotation } from '../src/utils/schema-utils.js'; test('schema-utils.js getExampleValuesFromSchema', t => { const schema = { @@ -97,3 +97,63 @@ const findPatternOwner = node => { t.pass(); }); }); + +const multiTypeProperty = { + type: ['array', 'object', 'integer', 'null'], + title: 'Tags', + description: 'Property description', + deprecated: true, + uniqueItems: true, + minItems: 1, + minimum: 0, + items: { type: 'object', description: 'Item description', properties: { id: { type: 'integer' } } }, + properties: { name: { type: 'string' } } +}; +const optionsOf = result => Object.entries(Object.entries(result).find(([key]) => key.startsWith('::ONE~OF'))[1]).filter(([key]) => key.startsWith('::OPTION')).map(([, option]) => option); + +test('schema-utils.js schemaInObjectNotation puts title, description and deprecation of a multi-type property on the property', t => { + const result = schemaInObjectNotation(multiTypeProperty, {}); + expect(result['::title']).to.equal('Tags'); + expect(result['::description']).to.equal('Property description'); + expect(result['::deprecated']).to.equal(true); + t.pass(); +}); + +test('schema-utils.js schemaInObjectNotation does not copy the property description or deprecation down to the type options', t => { + const [arrayOption, objectOption, ...primitiveOptions] = optionsOf(schemaInObjectNotation(multiTypeProperty, {})); + expect(arrayOption['::description']).to.equal('Item description'); + expect(arrayOption['::deprecated'] || false).to.equal(false); + expect(objectOption['::description'] || '').to.equal(''); + expect(objectOption['::deprecated'] || false).to.equal(false); + primitiveOptions.filter(o => o.startsWith('{')).map(o => JSON.parse(o)).forEach(option => { + expect(option.schemaDescription).to.equal(''); + expect(option.schemaTitle).to.equal(''); + expect(option.deprecated).to.equal(false); + }); + t.pass(); +}); + +test('schema-utils.js schemaInObjectNotation shows constraints only on the type option they apply to', t => { + const [arrayOption, objectOption, ...primitiveOptions] = optionsOf(schemaInObjectNotation(multiTypeProperty, {})); + expect(arrayOption['::metadata'].constraints).to.include('Requires unique items'); + expect(objectOption['::metadata']?.constraints || []).to.eql([]); + const integerOption = primitiveOptions.filter(o => o.startsWith('{')).map(o => JSON.parse(o)).find(o => o.type.includes('integer')); + expect(integerOption.constraints).to.eql(['Range: [0,)']); + t.pass(); +}); + +test('schema-utils.js schemaInObjectNotation marks a deprecated one-of property as deprecated', t => { + const result = schemaInObjectNotation({ deprecated: true, oneOf: [{ type: 'string' }, { type: 'integer', deprecated: true }] }, {}); + expect(result['::deprecated']).to.equal(true); + const [stringOption, integerOption] = optionsOf(result).map(o => JSON.parse(o)); + expect(stringOption.deprecated).to.equal(false); + expect(integerOption.deprecated).to.equal(true); + t.pass(); +}); + +test('schema-utils.js getTypeInfo shows the unique items constraint only for array types', t => { + expect(getTypeInfo({ type: ['integer', 'string', 'null'], uniqueItems: true }).constraints).to.eql([]); + expect(getTypeInfo({ type: 'array', items: { type: 'string' }, uniqueItems: true }).constraints).to.eql(['Requires unique items']); + expect(getTypeInfo({ type: 'string[]┃null', uniqueItems: true }).constraints).to.eql(['Requires unique items']); + t.pass(); +});