From bfbee1465c112c99edba5291d7d302e74a83c880 Mon Sep 17 00:00:00 2001 From: Dan Hudlow Date: Mon, 23 Dec 2024 15:03:01 -0600 Subject: [PATCH] feat: add `collectFromComposedSchemas()` and `getExamplesForSchema()` to utilities (#708) Signed-off-by: Dan Hudlow --- docs/openapi-ruleset-utilities.md | 30 ++- .../utils/collect-from-composed-schemas.js | 56 ++++++ .../src/utils/get-examples-for-schema.js | 29 +++ .../utils/get-property-names-for-schema.js | 40 ++-- packages/utilities/src/utils/index.js | 2 + .../collect-from-composed-schemas.test.js | 189 ++++++++++++++++++ .../test/get-examples-for-schema.test.js | 154 ++++++++++++++ 7 files changed, 474 insertions(+), 26 deletions(-) create mode 100644 packages/utilities/src/utils/collect-from-composed-schemas.js create mode 100644 packages/utilities/src/utils/get-examples-for-schema.js create mode 100644 packages/utilities/test/collect-from-composed-schemas.test.js create mode 100644 packages/utilities/test/get-examples-for-schema.test.js diff --git a/docs/openapi-ruleset-utilities.md b/docs/openapi-ruleset-utilities.md index cad92b29..139e9b7f 100644 --- a/docs/openapi-ruleset-utilities.md +++ b/docs/openapi-ruleset-utilities.md @@ -56,6 +56,32 @@ to a specific language type or data structure in an SDK). ## Functions +### `collectFromComposedSchemas(schema, collector, includeSelf, includeNot)` + +Returns an array of items collected by the provided `collector(schema) => item[]` function for a +simple or composite schema, and deduplicates primitives in the result. The collector function is +not run for `null` or `undefined` schemas. + +#### Parameters + +- **`schema`** ``: simple or composite OpenAPI 3.x schema object +- **`collector`** ``: a `(schema) => item[]` function to collect items from each simple schema +- **`includeSelf`** ``: collect from the provided schema in addition to its composed schemas (defaults to `true`) +- **`includeNot`** ``: collect from schemas composed with `not` (defaults to `false`) + +#### Returns `Array`: collected items + +### `getExamplesForSchema(schema)` + +Returns an array of examples for a simple or composite schema. For each composed schema, if +`schema.examples` is present (and an array), `schema.example` is ignored. + +#### Parameters + +- **`schema`** ``: simple or composite OpenAPI 3.x schema object + +#### Returns `Array`: examples + ### `getPropertyNamesForSchema(schema, propertyFilter)` Returns an array of property names for a simple or composite schema, @@ -63,8 +89,8 @@ optionally filtered by a lambda function. #### Parameters -- **`schema`** ``: simple or composite OpenAPI 3.0 schema object -- **`propertyFilter`** ``: a `(schema) => boolean` function to perform filtering +- **`schema`** ``: simple or composite OpenAPI 3.x schema object +- **`propertyFilter`** ``: a `(propertyName, propertySchema) => boolean` function to perform filtering #### Returns `Array`: property names diff --git a/packages/utilities/src/utils/collect-from-composed-schemas.js b/packages/utilities/src/utils/collect-from-composed-schemas.js new file mode 100644 index 00000000..e9196ba6 --- /dev/null +++ b/packages/utilities/src/utils/collect-from-composed-schemas.js @@ -0,0 +1,56 @@ +/** + * Copyright 2024 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +/** + * Returns an array of items collected by the provided `collector(schema) => item[]` function for a + * simple or composite schema, and deduplicates primitives in the result. The collector function is + * not run for `null` or `undefined` schemas. + * @param {object} schema simple or composite OpenAPI 3.x schema object + * @param {Function} collector a `(schema) => item[]` function to collect items from each simple schema + * @param {boolean} includeSelf collect from the provided schema in addition to its composed schemas (defaults to `true`) + * @param {boolean} includeNot collect from schemas composed with `not` (defaults to `false`) + * @returns {Array} collected items + */ +function collectFromComposedSchemas( + schema, + collector, + includeSelf = true, + includeNot = false +) { + const items = []; + + if (schema === undefined || schema === null) { + return items; + } + + if (includeSelf) { + items.push(...collector(schema)); + } + + if (includeNot) { + items.push( + ...collectFromComposedSchemas(schema.not, collector, true, true) + ); + } + + for (const applicatorType of ['allOf', 'oneOf', 'anyOf']) { + if (Array.isArray(schema[applicatorType])) { + for (const applicatorSchema of schema[applicatorType]) { + items.push( + ...collectFromComposedSchemas( + applicatorSchema, + collector, + true, + includeNot + ) + ); + } + } + } + + return [...new Set(items)]; // de-duplicate +} + +module.exports = collectFromComposedSchemas; diff --git a/packages/utilities/src/utils/get-examples-for-schema.js b/packages/utilities/src/utils/get-examples-for-schema.js new file mode 100644 index 00000000..bff15000 --- /dev/null +++ b/packages/utilities/src/utils/get-examples-for-schema.js @@ -0,0 +1,29 @@ +/** + * Copyright 2024 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +/** + * @private + */ +const collectFromComposedSchemas = require('./collect-from-composed-schemas'); + +/** + * Returns an array of examples for a simple or composite schema. For each composed schema, if + * `schema.examples` is present (and an array), `schema.example` is ignored. + * @param {object} schema simple or composite OpenAPI 3.x schema object + * @returns {Array} examples + */ +function getExamplesForSchema(schema) { + return collectFromComposedSchemas(schema, s => { + if (Array.isArray(s.examples)) { + return s.examples; + } else if (s.example !== undefined) { + return [s.example]; + } + + return []; + }); +} + +module.exports = getExamplesForSchema; diff --git a/packages/utilities/src/utils/get-property-names-for-schema.js b/packages/utilities/src/utils/get-property-names-for-schema.js index 96915ab3..9d3d2cc2 100644 --- a/packages/utilities/src/utils/get-property-names-for-schema.js +++ b/packages/utilities/src/utils/get-property-names-for-schema.js @@ -1,5 +1,5 @@ /** - * Copyright 2017 - 2023 IBM Corporation. + * Copyright 2017 - 2024 IBM Corporation. * SPDX-License-Identifier: Apache2.0 */ @@ -7,40 +7,32 @@ * @private */ const isObject = require('./is-object'); +/** + * @private + */ +const collectFromComposedSchemas = require('./collect-from-composed-schemas'); /** * Returns an array of property names for a simple or composite schema, * optionally filtered by a lambda function. - * @param {object} schema simple or composite OpenAPI 3.0 schema object - * @param {Function} propertyFilter a `(schema) => boolean` function to perform filtering + * @param {object} schema simple or composite OpenAPI 3.x schema object + * @param {Function} propertyFilter a `(propertyName, propertySchema) => boolean` function to perform filtering * @returns {Array} property names */ function getPropertyNamesForSchema(schema, propertyFilter = () => true) { - const propertyNames = []; - - if (!isObject(schema)) { - return propertyNames; - } + return collectFromComposedSchemas(schema, s => { + const propertyNames = []; - if (isObject(schema.properties)) { - for (const propertyName of Object.keys(schema.properties)) { - if (propertyFilter(propertyName, schema.properties[propertyName])) { - propertyNames.push(propertyName); + if (isObject(s.properties)) { + for (const propertyName of Object.keys(s.properties)) { + if (propertyFilter(propertyName, s.properties[propertyName])) { + propertyNames.push(propertyName); + } } } - } - for (const applicatorType of ['allOf', 'oneOf', 'anyOf']) { - if (Array.isArray(schema[applicatorType])) { - for (const applicatorSchema of schema[applicatorType]) { - propertyNames.push( - ...getPropertyNamesForSchema(applicatorSchema, propertyFilter) - ); - } - } - } - - return [...new Set(propertyNames)]; // de-duplicate + return propertyNames; + }); } module.exports = getPropertyNamesForSchema; diff --git a/packages/utilities/src/utils/index.js b/packages/utilities/src/utils/index.js index 1ef76c7f..0a2e928a 100644 --- a/packages/utilities/src/utils/index.js +++ b/packages/utilities/src/utils/index.js @@ -4,6 +4,8 @@ */ module.exports = { + collectFromComposedSchemas: require('./collect-from-composed-schemas'), + getExamplesForSchema: require('./get-examples-for-schema'), getPropertyNamesForSchema: require('./get-property-names-for-schema'), ...require('./get-schema-type'), isObject: require('./is-object'), diff --git a/packages/utilities/test/collect-from-composed-schemas.test.js b/packages/utilities/test/collect-from-composed-schemas.test.js new file mode 100644 index 00000000..41ca24a2 --- /dev/null +++ b/packages/utilities/test/collect-from-composed-schemas.test.js @@ -0,0 +1,189 @@ +/** + * Copyright 2024 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +const { collectFromComposedSchemas } = require('../src'); + +describe('Utility function: collectFromComposedSchemas()', () => { + it('should return `[]` for `undefined` or `null`', async () => { + expect(collectFromComposedSchemas(undefined, () => ['foo'])).toEqual([]); + expect(collectFromComposedSchemas(null, () => ['foo'])).toEqual([]); + }); + + it('should not run collector for `undefined` or `null`', async () => { + expect(() => + collectFromComposedSchemas(undefined, () => { + throw new Error(); + }) + ).not.toThrow(); + expect(() => + collectFromComposedSchemas(null, () => { + throw new Error(); + }) + ).not.toThrow(); + }); + + it('should collect once from a simple schema', async () => { + const schemaFoo = { foo: Math.random() }; + const collectedFrom = []; + + collectFromComposedSchemas(schemaFoo, s => { + collectedFrom.push(s); + + return []; + }); + + expect(collectedFrom.length).toEqual(1); + expect(collectedFrom[0]).toEqual(schemaFoo); + }); + + it('should collect from a composed schema', async () => { + const composedSchema = { + foo: Math.random(), + allOf: [ + { + foo: Math.random(), + }, + ], + oneOf: [ + { + foo: Math.random(), + }, + ], + anyOf: [ + { + foo: Math.random(), + }, + ], + }; + + expect( + collectFromComposedSchemas(composedSchema, s => [s.foo]).sort() + ).toEqual( + [ + composedSchema.foo, + composedSchema.allOf[0].foo, + composedSchema.oneOf[0].foo, + composedSchema.anyOf[0].foo, + ].sort() + ); + }); + + it('should collect from a deeply composed schema', async () => { + const deeplyComposedSchema = { + foo: Math.random(), + allOf: [ + { + foo: Math.random(), + oneOf: [ + { + foo: Math.random(), + anyOf: [ + { + foo: Math.random(), + }, + ], + }, + ], + }, + ], + }; + + expect( + collectFromComposedSchemas(deeplyComposedSchema, s => [s.foo]).sort() + ).toEqual( + [ + deeplyComposedSchema.foo, + deeplyComposedSchema.allOf[0].foo, + deeplyComposedSchema.allOf[0].oneOf[0].foo, + deeplyComposedSchema.allOf[0].oneOf[0].anyOf[0].foo, + ].sort() + ); + }); + + it('should de-duplicate primitive items collected in a composed schema', async () => { + const value = Math.random(); + + expect( + collectFromComposedSchemas( + { + foo: value, + allOf: [ + { + foo: value + 0, + }, + ], + }, + s => [s.foo] + ) + ).toEqual([value]); + }); + + it('should not deduplicate non-primitive examples for composed schema', async () => { + expect( + collectFromComposedSchemas( + { + foo: {}, + allOf: [ + { + foo: {}, + }, + ], + }, + s => [s.foo] + ) + ).toEqual([{}, {}]); + }); + + it('should not collect from self if includeSelf = false', async () => { + const composedSchema = { + foo: Math.random(), + allOf: [ + { + foo: Math.random(), + }, + ], + }; + + expect( + collectFromComposedSchemas(composedSchema, s => [s.foo], false).sort() + ).toEqual([composedSchema.allOf[0].foo].sort()); + }); + + it('should not collect from `not` if includeNot = false', async () => { + const composedSchema = { + foo: Math.random(), + not: { + foo: Math.random(), + }, + }; + + expect( + collectFromComposedSchemas( + composedSchema, + s => [s.foo], + true, + false + ).sort() + ).toEqual([composedSchema.foo].sort()); + }); + + it('should collect from `not` if includeNot = true', async () => { + const composedSchema = { + foo: Math.random(), + not: { + foo: Math.random(), + }, + }; + + expect( + collectFromComposedSchemas( + composedSchema, + s => [s.foo], + true, + true + ).sort() + ).toEqual([composedSchema.foo, composedSchema.not.foo].sort()); + }); +}); diff --git a/packages/utilities/test/get-examples-for-schema.test.js b/packages/utilities/test/get-examples-for-schema.test.js new file mode 100644 index 00000000..6b60988f --- /dev/null +++ b/packages/utilities/test/get-examples-for-schema.test.js @@ -0,0 +1,154 @@ +/** + * Copyright 2024 IBM Corporation. + * SPDX-License-Identifier: Apache2.0 + */ + +const { getExamplesForSchema } = require('../src'); + +describe('Utility function: getExamplesForSchema()', () => { + it('should return `[]` for `undefined`', async () => { + expect(getExamplesForSchema(undefined)).toEqual([]); + }); + + it('should return `[]` for `null`', async () => { + expect(getExamplesForSchema(null)).toEqual([]); + }); + + it('should return `[]` for empty object', async () => { + expect(getExamplesForSchema({})).toEqual([]); + }); + + it('should return examples for simple schema', async () => { + expect( + getExamplesForSchema({ + examples: ['three', 'two', 'one'], + }).sort() + ).toEqual(['three', 'two', 'one'].sort()); + }); + + it('should return single example for simple schema', async () => { + expect( + getExamplesForSchema({ + example: 'one', + }) + ).toEqual(['one']); + }); + + it('should return examples for composed schema', async () => { + expect( + getExamplesForSchema({ + examples: ['one'], + allOf: [ + { + examples: ['two'], + }, + ], + oneOf: [ + { + examples: ['three'], + }, + ], + anyOf: [ + { + examples: ['four'], + }, + ], + }).sort() + ).toEqual(['four', 'three', 'two', 'one'].sort()); + }); + + it('should return examples for deeply composed schema', async () => { + expect( + getExamplesForSchema({ + examples: ['one'], + allOf: [ + { + examples: ['two'], + oneOf: [ + { + examples: ['three'], + anyOf: [ + { + examples: ['four'], + }, + ], + }, + ], + }, + ], + }).sort() + ).toEqual(['four', 'three', 'two', 'one'].sort()); + }); + + it('should return examples for deeply composed schema', async () => { + expect( + getExamplesForSchema({ + examples: ['one'], + allOf: [ + { + examples: ['two'], + oneOf: [ + { + examples: ['three'], + anyOf: [ + { + examples: ['four'], + }, + ], + }, + ], + }, + ], + }).sort() + ).toEqual(['four', 'three', 'two', 'one'].sort()); + }); + + it('should aggregate examples for any mixture of schemas with `example` and `examples`', async () => { + expect( + getExamplesForSchema({ + examples: ['one'], + allOf: [ + { + example: 'two', + oneOf: [ + { + examples: ['three'], + anyOf: [ + { + example: 'four', + }, + ], + }, + ], + }, + ], + }).sort() + ).toEqual(['four', 'three', 'two', 'one'].sort()); + }); + + it('should de-duplicate primitive examples for composed schema', async () => { + expect( + getExamplesForSchema({ + examples: ['one'], + allOf: [ + { + examples: ['one'], + }, + ], + }) + ).toEqual(['one']); + }); + + it('should not deduplicate non-primitive examples for composed schema', async () => { + expect( + getExamplesForSchema({ + examples: [{}], + allOf: [ + { + examples: [{}], + }, + ], + }) + ).toEqual([{}, {}]); + }); +});