diff --git a/docs/reference/functions.md b/docs/reference/functions.md index 1620f4470..d9d655aba 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -245,9 +245,34 @@ unused-definition: reusableObjectsLocation: "#/definitions" ``` +## or + +Communicate that one or more of these properties is required to be defined. FunctionOptions must contain any non-zero number of properties, **or** will require that _at least_ one of them is defined. (For only one property specified, this is the same as the `defined` rule for that property.) + + + +| name | description | type | required? | +| ---------- | ----------------------- | ---------- | --------- | +| properties | the properties to check | `string[]` | yes | + + + +```yaml +schemas-descriptive-text-exists: + description: Defined schemas must have one or more of `title`, `summary` and/or `description` fields. + given: "$.components.schemas.*" + then: + function: or + functionOptions: + properties: + - title + - summary + - description +``` + ## 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 no more than one is allowed to be defined. FunctionOptions must contain any non-zero number of properties, **xor** will require that _exactly_ one of them is defined. (For only one property specified, this is the same as the `defined` rule for that property.) @@ -259,7 +284,7 @@ Communicate that one of these properties is required, and no more than one is al ```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. given: "$.components.examples.*" then: function: xor diff --git a/packages/functions/src/__tests__/or.test.ts b/packages/functions/src/__tests__/or.test.ts new file mode 100644 index 000000000..bf576f7ba --- /dev/null +++ b/packages/functions/src/__tests__/or.test.ts @@ -0,0 +1,243 @@ +import '@stoplight/spectral-test-utils/matchers'; + +import { RulesetValidationError } from '@stoplight/spectral-core'; +import testFunction from './__helpers__/tester'; +import or from '../or'; +import AggregateError = require('es-aggregate-error'); + +const runOr = testFunction.bind(null, or); + +describe('Core Functions / Or', () => { + it('given no properties, should return an error message', async () => { + expect( + await runOr( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['yada-yada', 'whatever'] }, + ), + ).toEqual([ + { + message: 'At least one of "yada-yada" or "whatever" must be defined', + path: [], + }, + ]); + }); + + it('given both properties, should return no error message', async () => { + expect( + await runOr( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['version', 'title'] }, + ), + ).toEqual([]); + }); + + it('given invalid input, should show no error message', async () => { + return expect(await runOr(null, { properties: ['version', 'title'] })).toEqual([]); + }); + + it('given only one of the properties, should return no error message', async () => { + expect( + await runOr( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['something', 'title'] }, + ), + ).toEqual([]); + }); + + it('given none of 1 property, should return an error message', async () => { + expect( + await runOr( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['yada-yada'] }, + ), + ).toEqual([ + { + message: 'At least one of "yada-yada" must be defined', + path: [], + }, + ]); + }); + + it('given only one of 1 property, should return no error message', async () => { + expect( + await runOr( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['title'] }, + ), + ).toEqual([]); + }); + + it('given one of 3 properties, should return no error message', async () => { + expect( + await runOr( + { + type: 'string', + format: 'date', + }, + { properties: ['default', 'pattern', 'format'] }, + ), + ).toEqual([]); + }); + + it('given two of 3 properties, should return no error message', async () => { + expect( + await runOr( + { + type: 'string', + default: '2024-05-01', + format: 'date', + }, + { properties: ['default', 'pattern', 'format'] }, + ), + ).toEqual([]); + }); + + it('given three of 3 properties, should return no error message', async () => { + expect( + await runOr( + { + type: 'string', + default: '2024-05-01', + pattern: '\\d{4}-\\d{2}-\\d{2}', + format: 'date', + }, + { properties: ['default', 'pattern', 'format'] }, + ), + ).toEqual([]); + }); + + it('given multiple of 5 properties, should return no error message', async () => { + expect( + await runOr( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['version', 'title', 'termsOfService', 'bar', 'five'] }, + ), + ).toEqual([]); + }); + + it('given none of 5 properties, should return an error message', async () => { + expect( + await runOr( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['yada-yada', 'foo', 'bar', 'four', 'five'] }, + ), + ).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, should return no error message', async () => { + expect( + await runOr( + { + version: '1.0.0', + title: 'Swagger Petstore', + termsOfService: 'http://swagger.io/terms/', + }, + { properties: ['title', 'foo', 'bar', 'four'] }, + ), + ).toEqual([]); + }); + + describe('validation', () => { + it.each([{ properties: ['foo', 'bar'] }])('given valid %p options, should not throw', async opts => { + expect(await runOr([], opts)).toEqual([]); + }); + + it.each([{ properties: ['foo'] }])('given valid %p options, should not throw', async opts => { + expect(await runOr([], opts)).toEqual([]); + }); + + it.each([{ properties: ['foo', 'bar', 'three'] }])('given valid %p options, should not throw', async opts => { + expect(await runOr([], opts)).toEqual([]); + }); + + it.each<[unknown, RulesetValidationError[]]>([ + [ + null, + [ + new RulesetValidationError( + 'invalid-function-options', + '"or" function has invalid options specified. Example valid options: { "properties": ["id"] }, { "properties": ["default", "example"] }, { "properties": ["title", "summary", "description"] }, etc.', + ['rules', 'my-rule', 'then', 'functionOptions'], + ), + ], + ], + [ + 2, + [ + new RulesetValidationError( + 'invalid-function-options', + '"or" function has invalid options specified. Example valid options: { "properties": ["id"] }, { "properties": ["default", "example"] }, { "properties": ["title", "summary", "description"] }, etc.', + ['rules', 'my-rule', 'then', 'functionOptions'], + ), + ], + ], + [ + { properties: ['foo', 'bar'], foo: true }, + [ + new RulesetValidationError('invalid-function-options', '"or" function does not support "foo" option', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + 'foo', + ]), + ], + ], + [ + { properties: ['foo', {}] }, + [ + new RulesetValidationError( + 'invalid-function-options', + '"or" requires one or more enumerated "properties", i.e. ["id"], ["default", "example"], ["title", "summary", "description"], etc.', + ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], + ), + ], + ], + [ + { properties: [] }, + [ + new RulesetValidationError( + 'invalid-function-options', + '"or" requires one or more enumerated "properties", i.e. ["id"], ["default", "example"], ["title", "summary", "description"], etc.', + ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], + ), + ], + ], + ])('given invalid %p options, should throw', async (opts, errors) => { + await expect(runOr({}, opts)).rejects.toThrowAggregateError(new AggregateError(errors)); + }); + }); +}); diff --git a/packages/functions/src/__tests__/xor.test.ts b/packages/functions/src/__tests__/xor.test.ts index 49d328f32..c9bc5b6b6 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: 'At least 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: 'At least 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,106 @@ describe('Core Functions / Xor', () => { ).toEqual([]); }); + 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: 'At least 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: '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, 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([]); + }); + describe('validation', () => { 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": ["country", "street"] }, { "properties": ["one", "two", "three"] }, etc.', ['rules', 'my-rule', 'then', 'functionOptions'], ), ], @@ -100,7 +188,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": ["country", "street"] }, { "properties": ["one", "two", "three"] }, etc.', ['rules', 'my-rule', 'then', 'functionOptions'], ), ], @@ -122,17 +210,7 @@ describe('Core Functions / Xor', () => { [ new RulesetValidationError( 'invalid-function-options', - '"xor" and its "properties" option require at least 2-item tuples, i.e. ["id", "name"]', - ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], - ), - ], - ], - [ - { properties: ['foo'] }, - [ - 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"], ["country", "street"], ["one", "two", "three"], etc.', ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], @@ -142,7 +220,7 @@ 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"], ["country", "street"], ["one", "two", "three"], etc.', ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], diff --git a/packages/functions/src/optionSchemas.ts b/packages/functions/src/optionSchemas.ts index 7a3a2741d..fe619fd01 100644 --- a/packages/functions/src/optionSchemas.ts +++ b/packages/functions/src/optionSchemas.ts @@ -96,6 +96,26 @@ export const optionSchemas: Record = { type: `"length" function has invalid options specified. Example valid options: { "min": 2 }, { "max": 5 }, { "min": 0, "max": 10 }`, }, }, + or: { + type: 'object', + properties: { + properties: { + type: 'array', + items: { + type: 'string', + }, + minItems: 1, // OR is valid with one item (then it is redundant with 'defined' function) + // maxItems: 2, // No maximum limit is necessary, OR is valid for any amount, just one must be defined + errorMessage: `"or" requires one or more enumerated "properties", i.e. ["id"], ["default", "example"], ["title", "summary", "description"], etc.`, + description: 'The properties to check.', + }, + }, + additionalProperties: false, + required: ['properties'], + errorMessage: { + type: `"or" function has invalid options specified. Example valid options: { "properties": ["id"] }, { "properties": ["default", "example"] }, { "properties": ["title", "summary", "description"] }, etc.`, + }, + }, pattern: { type: 'object', additionalProperties: false, @@ -206,15 +226,16 @@ 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"], ["country", "street"], ["one", "two", "three"], etc.`, description: 'The properties to check.', }, }, 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": ["country", "street"] }, { "properties": ["one", "two", "three"] }, etc.`, }, }, }; diff --git a/packages/functions/src/or.ts b/packages/functions/src/or.ts new file mode 100644 index 000000000..afed84f66 --- /dev/null +++ b/packages/functions/src/or.ts @@ -0,0 +1,41 @@ +import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; +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[]; +}; + +export default createRulesetFunction, Options>( + { + input: { + type: 'object', + }, + options: optionSchemas.or, + }, + function or(targetVal, { properties }) { + if (properties.length == 0) return; + // There need be no maximum limit on number of properties + + const results: IFunctionResult[] = []; + + const intersection = Object.keys(targetVal).filter(value => -1 !== properties.indexOf(value)); + 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: 'At least one of "' + shortprops.join('" or "') + '" or ' + count, + }); + } else { + // List all of one to four properties directly in error message + results.push({ + message: 'At least one of "' + properties.join('" or "') + '" must be defined', + }); + } + } + + return results; + }, +); diff --git a/packages/functions/src/xor.ts b/packages/functions/src/xor.ts index 5d5d68b78..10683819a 100644 --- a/packages/functions/src/xor.ts +++ b/packages/functions/src/xor.ts @@ -1,6 +1,4 @@ import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; -import { printValue } from '@stoplight/spectral-runtime'; - import { optionSchemas } from './optionSchemas'; export type Options = { @@ -16,20 +14,32 @@ export default createRulesetFunction, Options>( options: optionSchemas.xor, }, function xor(targetVal, { properties }) { - const results: IFunctionResult[] = []; - - const intersection = Object.keys(targetVal).filter(key => properties.includes(key)); + 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)); + 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: 'At least one of "' + shortprops.join('" or "') + '" or ' + count, + }); + } else { + // List all of one to four properties directly in error message + results.push({ + message: 'At least one of "' + properties.join('" or "') + '" must be defined', + }); + } + } + if (intersection.length > 1) { + // List all defined properties in error message results.push({ - message, + message: 'Just one of "' + intersection.join('" and "') + '" must be defined', }); }