diff --git a/.changeset/oneof-required-samples.md b/.changeset/oneof-required-samples.md new file mode 100644 index 0000000..5c636e1 --- /dev/null +++ b/.changeset/oneof-required-samples.md @@ -0,0 +1,5 @@ +--- +"openapi-sampler": patch +--- + +Fixed object samples with `oneOf` `required` so they match exactly one subschema. diff --git a/src/traverse.js b/src/traverse.js index b6f478a..29a404d 100644 --- a/src/traverse.js +++ b/src/traverse.js @@ -42,6 +42,35 @@ function tryInferExample(schema) { return; } +function getRequired(schema) { + return Array.isArray(schema && schema.required) ? schema.required : []; +} + +// oneOf is exclusive: a property required only by a sibling alternative would +// make the sample match more than one subschema (see issue #151). +function omitSiblingOneOfRequired(sampleValue, schema, selectedRequired) { + if (!sampleValue || typeof sampleValue !== 'object' || Array.isArray(sampleValue)) { + return; + } + + const keep = {}; + for (const prop of getRequired(schema)) { + keep[prop] = true; + } + for (const prop of selectedRequired) { + keep[prop] = true; + } + + for (let i = 1; i < schema.oneOf.length; i++) { + const altRequired = getRequired(schema.oneOf[i]); + for (const prop of altRequired) { + if (!keep[prop]) { + delete sampleValue[prop]; + } + } + } +} + export function traverse(schema, options, spec, context) { // checking circular JS references by checking context // because context is passed only when traversing through nested objects happens @@ -174,11 +203,22 @@ export function traverse(schema, options, spec, context) { return inferred; } - const localExample = traverse({ ...schema, oneOf: undefined, anyOf: undefined }, options, spec, context); + const rest = { ...schema, oneOf: undefined, anyOf: undefined }; + const selectedRequired = getRequired(selectedSubSchema); + + // Lift required from the selected alternative so skipNonRequired sees it. + if (schema.oneOf && selectedRequired.length) { + rest.required = getRequired(rest).concat(selectedRequired); + } + + const localExample = traverse(rest, options, spec, context); const subSchemaExample = traverse(selectedSubSchema, options, spec, context); if (typeof localExample.value === 'object' && typeof subSchemaExample.value === 'object') { const mergedExample = mergeDeep(localExample.value, subSchemaExample.value); + if (schema.oneOf) { + omitSiblingOneOfRequired(mergedExample, schema, selectedRequired); + } return { ...subSchemaExample, value: mergedExample }; } diff --git a/test/integration.spec.js b/test/integration.spec.js index 370ad58..1a74c02 100644 --- a/test/integration.spec.js +++ b/test/integration.spec.js @@ -638,6 +638,65 @@ describe('Integration', () => { expect(result).toEqual(expected); }); + it('should match exactly one oneOf subschema when required is used on alternatives', () => { + schema = { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + }, + oneOf: [ + { required: ['a'] }, + { required: ['b'] }, + ], + }; + result = sample(schema); + expected = { + a: 'string' + }; + expect(result).toEqual(expected); + }); + + it('should apply oneOf required when skipNonRequired is true', () => { + schema = { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + }, + oneOf: [ + { required: ['a'] }, + { required: ['b'] }, + ], + }; + result = sample(schema, { skipNonRequired: true }); + expected = { + a: 'string' + }; + expect(result).toEqual(expected); + }); + + it('should keep properties that are not required by other oneOf subschemas', () => { + schema = { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + c: { type: 'string' }, + }, + oneOf: [ + { required: ['a'] }, + { required: ['b'] }, + ], + }; + result = sample(schema); + expected = { + a: 'string', + c: 'string' + }; + expect(result).toEqual(expected); + }); + it('should support anyOf', () => { schema = { anyOf: [