diff --git a/docs/reference/functions.md b/docs/reference/functions.md index 1620f4470..380267fb3 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -247,23 +247,25 @@ unused-definition: ## xor -Communicate that one of these properties is required, and no more than one is allowed to be defined. +Communicate that one of these properties is required, and exactly one or more than one is allowed to be defined, depending on `exclusive` option. FunctionOptions must contain any non-zero number of properties, `exclusive: true` (the default) requires that _exactly_ one of them is defined, `exclusive: false` requires that _at least_ one but possibly more than one is defined. (For only one property specified, `exclusive` is not relevant and this is the same as the `defined` function for the single property.) -| name | description | type | required? | -| ---------- | ----------------------- | ---------- | --------- | -| properties | the properties to check | `string[]` | yes | +| name | description | type | required? | +| ---------- | ------------------------------------------------------------------------------------------- | ---------- | --------- | +| properties | the properties to check | `string[]` | yes | +| exclusive | `true` by default for `xor`; when not truthy multiple matches are allowed for `or` behavior | `boolean` | no | ```yaml components-examples-value-or-externalValue: - description: Examples should have either a `value` or `externalValue` field. + description: Examples should have either a `value` or `externalValue` field, but not both; unless `exclusive` is false given: "$.components.examples.*" then: function: xor functionOptions: + exclusive: false properties: - externalValue - value diff --git a/packages/functions/src/__tests__/xor.test.ts b/packages/functions/src/__tests__/xor.test.ts index 49d328f32..167ad1c2a 100644 --- a/packages/functions/src/__tests__/xor.test.ts +++ b/packages/functions/src/__tests__/xor.test.ts @@ -20,7 +20,7 @@ describe('Core Functions / Xor', () => { ), ).toEqual([ { - message: '"yada-yada" and "whatever" must not be both defined or both undefined', + message: 'Exactly one of "yada-yada" or "whatever" must be defined', path: [], }, ]); @@ -38,7 +38,7 @@ describe('Core Functions / Xor', () => { ), ).toEqual([ { - message: '"yada-yada", "whatever" and "foo" must not be both defined or both undefined', + message: 'Exactly one of "yada-yada" or "whatever" or "foo" must be defined', path: [], }, ]); @@ -56,13 +56,13 @@ describe('Core Functions / Xor', () => { ), ).toEqual([ { - message: '"version" and "title" must not be both defined or both undefined', + message: 'Just one of "version" and "title" must be defined', path: [], }, ]); }); - it('given invalid input, should should no error message', async () => { + it('given invalid input, should show no error message', async () => { return expect(await runXor(null, { properties: ['version', 'title'] })).toEqual([]); }); @@ -79,18 +79,268 @@ describe('Core Functions / Xor', () => { ).toEqual([]); }); - describe('validation', () => { + it('given none of 1 property, should return an error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['yada-yada'] }, + ), + ).toEqual([ + { + message: 'Exactly one of "yada-yada" must be defined', + path: [], + }, + ]); + }); + + it('given only one of 1 property, should return no error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['title'] }, + ), + ).toEqual([]); + }); + + it('given multiple of 5 properties, should return an error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['version', 'title', 'termsOfService', 'bar', 'five'] }, + ), + ).toEqual([ + { + message: 'Just one of "version" and "title" and "termsOfService" must be defined', + path: [], + }, + ]); + }); + + it('given none of 5 properties, should return an error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['yada-yada', 'foo', 'bar', 'four', 'five'] }, + ), + ).toEqual([ + { + message: 'Exactly one of "yada-yada" or "foo" or "bar" or 2 other properties must be defined', + path: [], + }, + ]); + }); + + it('given only one of 4 properties, should return no error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['title', 'foo', 'bar', 'four'] }, + ), + ).toEqual([]); + }); + + it('given no properties, for non-exclusive or, should return an error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['yada-yada', 'whatever'], exclusive: false }, + ), + ).toEqual([ + { + message: 'At least one of "yada-yada" or "whatever" must be defined', + path: [], + }, + ]); + }); + + it('given both properties, for non-exclusive or, should return no error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['version', 'title'], exclusive: false }, + ), + ).toEqual([]); + }); + + it('given invalid input, for non-exclusive or, should show no error message', async () => { + return expect(await runXor(null, { properties: ['version', 'title'], exclusive: false })).toEqual([]); + }); + + it('given only one of the properties, for non-exclusive or, should return no error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['something', 'title'], exclusive: false }, + ), + ).toEqual([]); + }); + + it('given none of 1 property, for non-exclusive or, should return an error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['yada-yada'], exclusive: false }, + ), + ).toEqual([ + { + message: 'At least one of "yada-yada" must be defined', + path: [], + }, + ]); + }); + + it('given only one of 1 property, for non-exclusive or, should return no error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['title'], exclusive: false }, + ), + ).toEqual([]); + }); + + it('given one of 3 properties, for non-exclusive or, should return no error message', async () => { + expect( + await runXor( + { + type: 'string', + format: 'date', + }, + { properties: ['default', 'pattern', 'format'], exclusive: false }, + ), + ).toEqual([]); + }); + + it('given two of 3 properties, for non-exclusive or, should return no error message', async () => { + expect( + await runXor( + { + type: 'string', + default: '2024-05-01', + format: 'date', + }, + { properties: ['default', 'pattern', 'format'], exclusive: false }, + ), + ).toEqual([]); + }); + + it('given three of 3 properties, for non-exclusive or, should return no error message', async () => { + expect( + await runXor( + { + type: 'string', + default: '2024-05-01', + pattern: '\\d{4}-\\d{2}-\\d{2}', + format: 'date', + }, + { properties: ['default', 'pattern', 'format'], exclusive: false }, + ), + ).toEqual([]); + }); + + it('given multiple of 5 properties, for non-exclusive or, should return no error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['version', 'title', 'termsOfService', 'bar', 'five'], exclusive: false }, + ), + ).toEqual([]); + }); + + it('given none of 5 properties, for non-exclusive or, should return an error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['yada-yada', 'foo', 'bar', 'four', 'five'], exclusive: false }, + ), + ).toEqual([ + { + message: 'At least one of "yada-yada" or "foo" or "bar" or 2 other properties must be defined', + path: [], + }, + ]); + }); + + it('given only one of 4 properties, for non-exclusive or, should return no error message', async () => { + expect( + await runXor( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['title', 'foo', 'bar', 'four'], exclusive: false }, + ), + ).toEqual([]); + }); + + describe('validation for exclusive xor', () => { it.each([{ properties: ['foo', 'bar'] }])('given valid %p options, should not throw', async opts => { expect(await runXor([], opts)).toEqual([]); }); + it.each([{ properties: ['foo'] }])('given valid %p options, should not throw', async opts => { + expect(await runXor([], opts)).toEqual([]); + }); + + it.each([{ properties: ['foo', 'bar', 'three'] }])('given valid %p options, should not throw', async opts => { + expect(await runXor([], opts)).toEqual([]); + }); + it.each<[unknown, RulesetValidationError[]]>([ [ null, [ new RulesetValidationError( 'invalid-function-options', - '"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }', + '"xor" function has invalid options specified. Example valid options: { "properties": ["id"] }, { "properties": ["value", "externalValue"], "exclusive": true }, { "properties": ["title", "summary", "description"], "exclusive": false }, etc.', ['rules', 'my-rule', 'then', 'functionOptions'], ), ], @@ -100,7 +350,7 @@ describe('Core Functions / Xor', () => { [ new RulesetValidationError( 'invalid-function-options', - '"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }', + '"xor" function has invalid options specified. Example valid options: { "properties": ["id"] }, { "properties": ["value", "externalValue"], "exclusive": true }, { "properties": ["title", "summary", "description"], "exclusive": false }, etc.', ['rules', 'my-rule', 'then', 'functionOptions'], ), ], @@ -122,27 +372,123 @@ describe('Core Functions / Xor', () => { [ new RulesetValidationError( 'invalid-function-options', - '"xor" and its "properties" option require at least 2-item tuples, i.e. ["id", "name"]', + '"xor" requires one or more enumerated "properties", i.e. ["id"], ["value", "externalValue"], ["title", "summary", "description"], etc.', ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], ], [ - { properties: ['foo'] }, + { properties: [] }, [ new RulesetValidationError( 'invalid-function-options', - '"xor" and its "properties" option require at least 2-item tuples, i.e. ["id", "name"]', + '"xor" requires one or more enumerated "properties", i.e. ["id"], ["value", "externalValue"], ["title", "summary", "description"], etc.', ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], ], + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runXor({}, opts)).rejects.toThrowAggregateError(new AggregateError(errors)); + }); + }); + + describe('validation for non-exclusive or', () => { + it.each([{ properties: ['foo', 'bar'], exclusive: true }])('given valid %p options, should not throw', async opts => { + expect(await runXor([], opts)).toEqual([]); + }); + + it.each([{ properties: ['foo'], exclusive: false }])('given valid %p options, should not throw', async opts => { + expect(await runXor([], opts)).toEqual([]); + }); + + it.each([{ properties: ['foo', 'bar', 'three'], exclusive: false }])('given valid %p options, should not throw', async opts => { + expect(await runXor([], opts)).toEqual([]); + }); + + it.each<[unknown, RulesetValidationError[]]>([ [ - { properties: [] }, + null, + [ + new RulesetValidationError( + 'invalid-function-options', + '"xor" function has invalid options specified. Example valid options: { "properties": ["id"] }, { "properties": ["value", "externalValue"], "exclusive": true }, { "properties": ["title", "summary", "description"], "exclusive": false }, etc.', + ['rules', 'my-rule', 'then', 'functionOptions'], + ), + ], + ], + [ + 2, + [ + new RulesetValidationError( + 'invalid-function-options', + '"xor" function has invalid options specified. Example valid options: { "properties": ["id"] }, { "properties": ["value", "externalValue"], "exclusive": true }, { "properties": ["title", "summary", "description"], "exclusive": false }, etc.', + ['rules', 'my-rule', 'then', 'functionOptions'], + ), + ], + ], + [ + { exclusive: false }, + [ + new RulesetValidationError( + 'invalid-function-options', + '"xor" function is missing "properties" option', + ['rules', 'my-rule', 'then', 'functionOptions'], + ), + ], + ], + [ + { exclusive: "false" }, + [ + new RulesetValidationError( + 'invalid-function-options', + '"xor" function is missing "properties" option', + ['rules', 'my-rule', 'then', 'functionOptions'], + ), + new RulesetValidationError( + 'invalid-function-options', + '"xor" function and its "exclusive" option accepts only the following types: boolean', + ['rules', 'my-rule', 'then', 'functionOptions', 'exclusive'], + ), + ], + ], + [ + { properties: ['foo', 'bar'], exclusive: "false" }, + [ + new RulesetValidationError( + 'invalid-function-options', + '"xor" function and its "exclusive" option accepts only the following types: boolean', + ['rules', 'my-rule', 'then', 'functionOptions', 'exclusive'], + ), + ], + ], + [ + { properties: ['foo', 'bar'], foo: true, exclusive: false }, + [ + new RulesetValidationError('invalid-function-options', '"xor" function does not support "foo" option', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + 'foo', + ]), + ], + ], + [ + { properties: ['foo', {}], exclusive: false }, + [ + new RulesetValidationError( + 'invalid-function-options', + '"xor" requires one or more enumerated "properties", i.e. ["id"], ["value", "externalValue"], ["title", "summary", "description"], etc.', + ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], + ), + ], + ], + [ + { properties: [], exclusive: false }, [ new RulesetValidationError( 'invalid-function-options', - '"xor" and its "properties" option require at least 2-item tuples, i.e. ["id", "name"]', + '"xor" requires one or more enumerated "properties", i.e. ["id"], ["value", "externalValue"], ["title", "summary", "description"], etc.', ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], diff --git a/packages/functions/src/optionSchemas.ts b/packages/functions/src/optionSchemas.ts index 7a3a2741d..820a3215c 100644 --- a/packages/functions/src/optionSchemas.ts +++ b/packages/functions/src/optionSchemas.ts @@ -206,15 +206,21 @@ export const optionSchemas: Record = { items: { type: 'string', }, - minItems: 2, - errorMessage: `"xor" and its "properties" option require at least 2-item tuples, i.e. ["id", "name"]`, + minItems: 1, // XOR is valid with one item (then it is redundant with 'defined' function) + // maxItems: 2, // No maximum limit is necessary, XOR is valid for any amount, just one must be defined + errorMessage: `"xor" requires one or more enumerated "properties", i.e. ["id"], ["value", "externalValue"], ["title", "summary", "description"], etc.`, description: 'The properties to check.', }, + exclusive: { + type: 'boolean', + default: true, + description: 'Defaults to true. If false, multiple matches are allowed.', + }, }, additionalProperties: false, required: ['properties'], errorMessage: { - type: `"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }`, + type: `"xor" function has invalid options specified. Example valid options: { "properties": ["id"] }, { "properties": ["value", "externalValue"], "exclusive": true }, { "properties": ["title", "summary", "description"], "exclusive": false }, etc.`, }, }, }; diff --git a/packages/functions/src/xor.ts b/packages/functions/src/xor.ts index 5d5d68b78..ac9b2cb87 100644 --- a/packages/functions/src/xor.ts +++ b/packages/functions/src/xor.ts @@ -1,11 +1,10 @@ import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; -import { printValue } from '@stoplight/spectral-runtime'; - import { optionSchemas } from './optionSchemas'; export type Options = { /** test to verify if one (but not all) of the provided keys are present in object */ properties: string[]; + exclusive?: boolean; }; export default createRulesetFunction, Options>( @@ -15,21 +14,39 @@ export default createRulesetFunction, Options>( }, options: optionSchemas.xor, }, - function xor(targetVal, { properties }) { - const results: IFunctionResult[] = []; - - const intersection = Object.keys(targetVal).filter(key => properties.includes(key)); + function xor(targetVal, opts: Options) { + const properties = opts.properties; + if (properties.length == 0) return; + // There need be no maximum limit on number of properties - if (intersection.length !== 1) { - const formattedProperties = properties.map(prop => printValue(prop)); - - const lastProperty = formattedProperties.pop(); - let message = formattedProperties.join(', ') + (lastProperty != undefined ? ` and ${lastProperty}` : ''); + const results: IFunctionResult[] = []; - message += ' must not be both defined or both undefined'; + const intersection = Object.keys(targetVal).filter(value => -1 !== properties.indexOf(value)); + const exclusive = (typeof opts.exclusive === 'boolean') ? opts.exclusive : true; + const exactlyOrAtLeast = exclusive ? "Exactly" : "At least"; + + // One-must-be-defined validation of both xor and or (non-exclusive) functions + if (intersection.length == 0) { + if (properties.length > 4) { + // List first three properties and remaining count in error message + const shortprops = properties.slice(0, 3); + const count = String(properties.length - 3) + ' other properties must be defined'; + results.push({ + message: exactlyOrAtLeast + ' one of "' + shortprops.join('" or "') + '" or ' + count, + }); + } else { + // List all of one to four properties directly in error message + results.push({ + message: exactlyOrAtLeast + ' one of "' + properties.join('" or "') + '" must be defined', + }); + } + } + // Maximum-one-defined validation of xor function only + if (exclusive && intersection.length > 1) { + // List all defined properties in error message results.push({ - message, + message: 'Just one of "' + intersection.join('" and "') + '" must be defined', }); }