From ccab37376ee9291dee4fb96404e7da0f37ba8e64 Mon Sep 17 00:00:00 2001 From: Leo Liang Date: Wed, 1 Dec 2021 05:52:27 +0000 Subject: [PATCH] feat: disable auto sample for non-required properties --- README.md | 2 ++ src/samplers/array.js | 12 ++++++--- src/samplers/boolean.js | 5 +++- src/samplers/number.js | 5 +++- src/samplers/object.js | 31 +++++++++++++++++----- src/samplers/string.js | 3 +++ src/traverse.js | 6 ++--- test/integration.spec.js | 56 ++++++++++++++++++++++++++++++++++++++++ test/unit/array.spec.js | 41 +++++++++++++++++++++++++++++ test/unit/number.spec.js | 42 ++++++++++++++++-------------- test/unit/object.spec.js | 46 ++++++++++++++++++++++++++++++++- test/unit/string.spec.js | 5 ++++ 12 files changed, 218 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7ddf459..f15ca28 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Available options: Don't include `readOnly` object properties - **skipWriteOnly** - `boolean` Don't include `writeOnly` object properties + - **disableNonRequiredAutoGen** - `boolean` + Don't auto generate sample for non-required object properties when the schema hasn't explicit example nor default value - **quiet** - `boolean` Don't log console warning messages - **spec** - whole specification where the schema is taken from. Required only when schema may contain `$ref`. **spec** must not contain any external references diff --git a/src/samplers/array.js b/src/samplers/array.js index ca66e74..44e20c5 100644 --- a/src/samplers/array.js +++ b/src/samplers/array.js @@ -9,7 +9,7 @@ export function sampleArray(schema, options = {}, spec, context) { arrayLength = Math.max(arrayLength, items.length); } - let itemSchemaGetter = itemNumber => { + const itemSchemaGetter = itemNumber => { if (Array.isArray(schema.items)) { return items[itemNumber] || {}; } @@ -20,9 +20,13 @@ export function sampleArray(schema, options = {}, spec, context) { if (!items) return res; for (let i = 0; i < arrayLength; i++) { - let itemSchema = itemSchemaGetter(i); - let { value: sample } = traverse(itemSchema, options, spec, {depth: depth + 1}); + let { value: sample } = traverse(itemSchemaGetter(i), options, spec, {depth: depth + 1}); res.push(sample); } - return res; + + if (!options.omissible || res.some(item => item !== null)) { + return res; + } + + return null; } diff --git a/src/samplers/boolean.js b/src/samplers/boolean.js index 6cdf21e..2922440 100644 --- a/src/samplers/boolean.js +++ b/src/samplers/boolean.js @@ -1,3 +1,6 @@ -export function sampleBoolean(schema) { +export function sampleBoolean(schema, options={}) { + if (options.omissible) { + return null; + } return true; // let be optimistic :) } diff --git a/src/samplers/number.js b/src/samplers/number.js index dcfc387..4ff8945 100644 --- a/src/samplers/number.js +++ b/src/samplers/number.js @@ -1,4 +1,7 @@ -export function sampleNumber(schema) { +export function sampleNumber(schema, options={}) { + if (options.omissible) { + return null; + } let res = 0; if (typeof schema.exclusiveMinimum === 'boolean' || typeof schema.exclusiveMaximum === 'boolean') { //legacy support for jsonschema draft 4 of exclusiveMaximum/exclusiveMinimum as booleans if (schema.maximum && schema.minimum) { diff --git a/src/samplers/object.js b/src/samplers/object.js index 1592da0..538581e 100644 --- a/src/samplers/object.js +++ b/src/samplers/object.js @@ -4,19 +4,28 @@ export function sampleObject(schema, options = {}, spec, context) { const depth = (context && context.depth || 1); if (schema && typeof schema.properties === 'object') { - let requiredKeys = (Array.isArray(schema.required) ? schema.required : []); - let requiredKeyDict = requiredKeys.reduce((dict, key) => { + const requiredKeys = (Array.isArray(schema.required) ? schema.required : []); + const requiredKeyDict = requiredKeys.reduce((dict, key) => { dict[key] = true; return dict; }, {}); Object.keys(schema.properties).forEach(propertyName => { + const isRequired = requiredKeyDict.hasOwnProperty(propertyName); // skip before traverse that could be costly - if (options.skipNonRequired && !requiredKeyDict.hasOwnProperty(propertyName)) { + if (options.skipNonRequired && !isRequired) { return; } - const sample = traverse(schema.properties[propertyName], options, spec, { propertyName, depth: depth + 1 }); + const propertyOmissible = options.disableNonRequiredAutoGen && !isRequired; + + const sample = traverse( + schema.properties[propertyName], + Object.assign({}, options, { omissible: propertyOmissible }), + spec, + { propertyName, depth: depth + 1 } + ); + if (options.skipReadOnly && sample.readOnly) { return; } @@ -24,13 +33,21 @@ export function sampleObject(schema, options = {}, spec, context) { if (options.skipWriteOnly && sample.writeOnly) { return; } - res[propertyName] = sample.value; + + if (sample.value || !propertyOmissible) { + res[propertyName] = sample.value; + } }); } - if (schema && typeof schema.additionalProperties === 'object') { + if (!options.disableNonRequiredAutoGen && schema && typeof schema.additionalProperties === 'object') { res.property1 = traverse(schema.additionalProperties, options, spec, {depth: depth + 1 }).value; res.property2 = traverse(schema.additionalProperties, options, spec, {depth: depth + 1 }).value; } - return res; + + if (Object.keys(res).length > 0 || !options.omissible) { + return res; + } + + return null; } diff --git a/src/samplers/string.js b/src/samplers/string.js index fa238c3..44db947 100644 --- a/src/samplers/string.js +++ b/src/samplers/string.js @@ -124,6 +124,9 @@ const stringFormats = { }; export function sampleString(schema, options, spec, context) { + if (options && options.omissible) { + return null; + } let format = schema.format || 'default'; let sampler = stringFormats[format] || defaultSample; let propertyName = context && context.propertyName; diff --git a/src/traverse.js b/src/traverse.js index bc9d2a8..c27968c 100644 --- a/src/traverse.js +++ b/src/traverse.js @@ -13,13 +13,13 @@ export function clearCache() { seenSchemasStack = []; } -function inferExample(schema) { +function inferExample(schema, omissible=false) { let example; if (schema.const !== undefined) { example = schema.const; } else if (schema.examples !== undefined && schema.examples.length) { example = schema.examples[0]; - } else if (schema.enum !== undefined && schema.enum.length) { + } else if (schema.enum !== undefined && schema.enum.length && !omissible) { example = schema.enum[0]; } else if (schema.default !== undefined) { example = schema.default; @@ -127,7 +127,7 @@ export function traverse(schema, options, spec, context) { return tryInferExample(schema) || traverse(mergeDeep(schema.if, schema.then), options, spec, context); } - let example = inferExample(schema); + let example = inferExample(schema, options.omissible); let type = null; if (example === undefined) { example = null; diff --git a/test/integration.spec.js b/test/integration.spec.js index 3a3b94d..0dc7635 100644 --- a/test/integration.spec.js +++ b/test/integration.spec.js @@ -432,6 +432,62 @@ describe('Integration', function() { expected = 'test1'; expect(result).to.equal(expected); }); + + it('should skip non-required properties without example if disableNonRequiredAutoGen=true', () => { + var obj = { + withExample: 'Example', + withExampleArray: [3], + }; + schema = { + type: 'object', + properties: { + withoutExampleString: { + type: 'string' + }, + withoutExampleNumber: { + type: 'number' + }, + withoutExampleBoolean: { + type: 'boolean' + }, + withoutExampleObject: { + type: 'object', + }, + withoutExampleArray: { + type: 'array', + items: { type: 'string'} + }, + withExample: { + type: 'string', + example: 'Example' + }, + withExampleArray: { + type: 'array', + items: { type: 'number', default: 3 } + } + }, + additionalProperties: { + type: 'number' + } + }; + result = OpenAPISampler.sample(schema, { disableNonRequiredAutoGen: true }); + expected = obj; + expect(result).to.deep.equal(obj); + }); + + it('should return empty object if disableNonRequiredAutoGen=true and no explicit example', () => { + var obj = {}; + schema = { + type: 'object', + properties: { + withoutExampleString: { type: 'string' }, + } + }; + result = OpenAPISampler.sample(schema, { disableNonRequiredAutoGen: true }); + expected = obj; + expect(result).to.deep.equal(obj); + }); + }); describe('Detection', function() { diff --git a/test/unit/array.spec.js b/test/unit/array.spec.js index 5c2acb8..f65db70 100644 --- a/test/unit/array.spec.js +++ b/test/unit/array.spec.js @@ -29,4 +29,45 @@ describe('sampleArray', () => { res = sampleArray({items: [{type: 'number'}, {type: 'string'}, {}]}); expect(res).to.deep.equal([0, 'string', null]); }); + + describe('disableNonRequiredAutoGen', () => { + + it('should return null if omissible=true and primitive type item has no example', () => { + res = sampleArray({ items: { type: 'string' } }, { omissible: true, disableNonRequiredAutoGen: true }); + expect(res).to.be.null; + }); + + it('should return null if omssible=true and object type item has no example', () => { + res = sampleArray({ + items: { + type: 'object', + properties: { + a: { type: 'string' }, + }, + } + }, { omissible: true, disableNonRequiredAutoGen: true }); + expect(res).to.be.null; + }); + + it('should return valid array samples if omissible=false and primitive type item has no example', () => { + // the sample must be valid to schema and show the array item type when the array is not omitted + res = sampleArray({ items: { type: 'string' }, minItems: 2 }, { disableNonRequiredAutoGen: true }); + expect(res).to.deep.equal(['string', 'string']); + }); + + it('should return array of empty object if omssible=false and object type item has no example', () => { + // the sample must be valid to schema and show the array item type when the array is not omitted + res = sampleArray({ + items: { + type: 'object', + properties: { + a: { type: 'string' }, + }, + } + }, { disableNonRequiredAutoGen: true }); + expect(res).to.deep.equal([{}]); + }); + + }); + }); diff --git a/test/unit/number.spec.js b/test/unit/number.spec.js index 424cd23..1a96ca5 100644 --- a/test/unit/number.spec.js +++ b/test/unit/number.spec.js @@ -57,26 +57,30 @@ describe('sampleNumber', () => { expect(res).to.equal(-4); }); - // (2, 3) -> 2.5 - it('should return middle point if boundary integer is not possible for draft v7', () => { - res = sampleNumber({exclusiveMinimum: 2, exclusiveMaximum: 3}); - expect(res).to.equal(2.5); - }); + // (2, 3) -> 2.5 + it('should return middle point if boundary integer is not possible for draft v7', () => { + res = sampleNumber({exclusiveMinimum: 2, exclusiveMaximum: 3}); + expect(res).to.equal(2.5); + }); - // [2, 3] -> 2 - // (8, 13) -> 9 - it('should return closer to minimum possible int for draft v7', () => { - res = sampleNumber({minimum: 2, maximum: 3}); - expect(res).to.equal(2); - res = sampleNumber({exclusiveMinimum: 8, exclusiveMaximum: 13}); - expect(res).to.equal(9); - }); + // [2, 3] -> 2 + // (8, 13) -> 9 + it('should return closer to minimum possible int for draft v7', () => { + res = sampleNumber({minimum: 2, maximum: 3}); + expect(res).to.equal(2); + res = sampleNumber({exclusiveMinimum: 8, exclusiveMaximum: 13}); + expect(res).to.equal(9); + }); - it('should return closer to minimum possible int for draft v7', () => { - res = sampleNumber({minimum: 2, maximum: 3}); - expect(res).to.equal(2); - res = sampleNumber({exclusiveMinimum: 8, exclusiveMaximum: 13}); - expect(res).to.equal(9); - }); + it('should return closer to minimum possible int for draft v7', () => { + res = sampleNumber({minimum: 2, maximum: 3}); + expect(res).to.equal(2); + res = sampleNumber({exclusiveMinimum: 8, exclusiveMaximum: 13}); + expect(res).to.equal(9); + }); + it('should return null if it is omissible', () => { + res = sampleNumber({}, { omissible: true }); + expect(res).to.be.null; + }); }); diff --git a/test/unit/object.spec.js b/test/unit/object.spec.js index 538623c..d413dbf 100644 --- a/test/unit/object.spec.js +++ b/test/unit/object.spec.js @@ -174,5 +174,49 @@ describe('sampleObject', () => { fooId: 'fb4274c7-4fcd-4035-8958-a680548957ff', barId: '3c966637-4898-4972-9a9d-baefa6cd6c89' }); - }) + }); + + describe('disableNonRequiredAutoGen', () => { + + it('should skip non-required properties without explicit example value', () => { + res = sampleObject({ + required: ['e'], + properties: { + a: { type: 'string', enum: ['foo', 'bar'] }, + b: { type: 'integer', default: 100 }, + c: { type: 'string' }, + d: { type: 'string', example: 'Example' }, + e: { type: 'string', enum: ['foo', 'bar'] }, + }, + }, { disableNonRequiredAutoGen: true }); + expect(res).to.deep.equal({ + b: 100, + d: 'Example', + e: 'foo', + }); + }); + + it('should skip additional properties', () => { + res = sampleObject({ + properties: { + a: { type: 'string', example: 'Example' }, + }, + additionalProperties: { type: 'string' } + }, { disableNonRequiredAutoGen: true }); + expect(res).to.deep.equal({ + a: 'Example' + }); + }); + + it('should return null if omissible=true and no property has example', () => { + res = sampleObject({ + properties: { + a: { type: 'string' }, + b: { type: 'integer' } + }, + }, { disableNonRequiredAutoGen: true, omissible: true }); + expect(res).to.be.null; + }); + + }); }); diff --git a/test/unit/string.spec.js b/test/unit/string.spec.js index 63d4a15..851c0f7 100644 --- a/test/unit/string.spec.js +++ b/test/unit/string.spec.js @@ -130,6 +130,11 @@ describe('sampleString', () => { expect(res).to.equal('fb4274c7-4fcd-4035-8958-a680548957ff'); }); + it('should return null if it is omissible', () => { + res = sampleString({}, { omissible: true }); + expect(res).to.be.null; + }); + it.each([ 'email', // 'idn-email', // unsupported by ajv-formats