diff --git a/assets/scripts/build-api-pages.js b/assets/scripts/build-api-pages.js index c5b218fcfdd..e9d3a308dfe 100644 --- a/assets/scripts/build-api-pages.js +++ b/assets/scripts/build-api-pages.js @@ -838,6 +838,115 @@ const descColumn = (key, value) => { return `
${descHtml}${def}
`.trim(); }; +/** + * Shim for Map.groupBy(), which is only available in Node 21+ (this repo + * targets Node 20). + * + * @param {Iterable} items - items to group + * @param {function} keyFn - maps an item to its group key + * @returns {Map} map of key to array of items + */ +const groupBy = (items, keyFn) => { + const map = new Map(); + items.forEach((item, i) => { + const key = keyFn(item, i); + const group = map.get(key); + if (group) { + group.push(item); + } else { + map.set(key, [item]); + } + }); + return map; +}; + +/** + * Returns "" if `item` is an object schema with a const `type` field + * @param {object} schema + * Returns `` when `item.properties[discriminant]` is a + * single-value string enum. + * @param {object} item - schema object + * @param {string} discriminant - property name to inspect + * @returns {string|undefined} formatted label, or undefined if not a const + */ +const getMaybeConstTypeName = (item, discriminant) => { + const type = item.properties?.[discriminant]; + if ( + type + && type.type === 'string' + && type.enum + && type.enum.length === 1 + ) { + return `<${discriminant}=${type.enum[0]}>`; + } + return undefined; +}; + +/** + * Auto-detects the best discriminant property across a set of oneOf branches. + * + * A property is a candidate discriminant when every branch either omits it or + * defines it as a string enum. + * + * Preference order: + * 1. The first candidate that covers every branch (all-defined + unique). + * 2. Otherwise, the candidate covering the most branches, provided it covers + * all-but-one branch or at least 75% of branches. + * + * @param {array} items - oneOf items + * @returns {string|undefined} the discriminant property name, or undefined + */ +const getBestDiscriminant = (items) => { + const candidates = Array.from(new Set( + items.flatMap((item) => Object.keys(item.properties ?? {})) + )); + const enumCandidates = candidates.filter((d) => items.some((item) => { + const prop = item.properties?.[d]; + return prop && prop.type === 'string' && prop.enum?.length === 1; + })); + const onlyEnumCandidates = enumCandidates.filter((d) => !items.some((item) => { + const prop = item.properties?.[d]; + return prop && (prop.type !== 'string' || !prop.enum); + })); + + const countedCandidates = onlyEnumCandidates.map((d) => { + const byName = groupBy(items, (item) => getMaybeConstTypeName(item, d)); + const nUnique = [...byName].reduce( + (acc, [value, arr]) => acc + ((value && arr.length === 1) ? 1 : 0), + 0, + ); + return { name: d, nUnique }; + }); + countedCandidates.sort((a, b) => b.nUnique - a.nUnique); + + const bestCandidate = countedCandidates[0]; + if (!bestCandidate) { + return undefined; + } + + const { name, nUnique } = bestCandidate; + if (nUnique >= items.length - 1 || (nUnique / items.length) >= 0.75) { + return name; + } + + return undefined; +}; + +/** + * @param {array} items - oneOf items + * @returns {object} object keyed by item name + */ +const getOneOfChildData = (items) => { + const discriminant = getBestDiscriminant(items); + const namedEntries = items.map((item) => [discriminant ? getMaybeConstTypeName(item, discriminant) : undefined, item]); + const byName = groupBy(namedEntries, ([name]) => name); + const entries = namedEntries.map(([name, item], i) => + // if name is unique, use it; otherwise, use "Object 3" + [name && byName.get(name)?.length === 1 ? name : `Object ${i + 1}`, item] + ); + return Object.fromEntries(entries); +}; + /** * Takes a application/json schema for request or response and outputs a table @@ -881,11 +990,7 @@ const rowRecursive = (tableType, data, isNested, requiredFields=[], level = 0, p } // for items -> oneOf if (value.items.oneOf && value.items.oneOf instanceof Array && value.items.oneOf.length < oneOfLimit) { - childData = value.items.oneOf - .map((obj, indx) => { - return {[`Option ${indx + 1}`]: value.items.oneOf[indx]} - }) - .reduce((obj, item) => ({...obj, ...item}), {}); + childData = getOneOfChildData(value.items.oneOf); } } else if(typeof value.items === 'string') { if(value.items === '[Circular]') { @@ -904,11 +1009,7 @@ const rowRecursive = (tableType, data, isNested, requiredFields=[], level = 0, p } else if (typeof value === 'object' && "oneOf" in value) { // for properties -> oneOf if(value.oneOf instanceof Array && value.oneOf.length < oneOfLimit) { - childData = value.oneOf - .map((obj, indx) => { - return {[`Option ${indx + 1}`]: value.oneOf[indx]} - }) - .reduce((obj, item) => ({...obj, ...item}), {}); + childData = getOneOfChildData(value.oneOf); } } // for widgets @@ -1103,12 +1204,17 @@ const processSpecs = (specs) => { if(deref.components.schemas && deref.components.schemas.WidgetDefinition && deref.components.schemas.WidgetDefinition.oneOf) { const jsonData = {}; const pageDir = `./content/en/api/${version}/dashboards/`; - deref.components.schemas.WidgetDefinition.oneOf.forEach((widget) => { + const addWidget = (widget) => { + if (widget.oneOf) { + widget.oneOf.forEach(addWidget); + return; + } const requestJson = filterExampleJson("request", widget); const requestCurlJson = filterExampleJson("curl", widget); const html = schemaTable("request", widget); jsonData[widget.properties.type.default] = {"json_curl": requestCurlJson, "json": requestJson, "html": html}; - }); + }; + deref.components.schemas.WidgetDefinition.oneOf.forEach(addWidget); fs.writeFileSync(`${pageDir}widgets.json`, safeJsonStringify(jsonData, null, 2), 'utf-8'); } @@ -1156,6 +1262,7 @@ const init = () => { module.exports = { init, + getBestDiscriminant, isTagMatch, isReadOnlyRow, descColumn, diff --git a/assets/scripts/tests/build-api-pages.test.js b/assets/scripts/tests/build-api-pages.test.js index 6e9dc7e25e7..11427e7b6a8 100644 --- a/assets/scripts/tests/build-api-pages.test.js +++ b/assets/scripts/tests/build-api-pages.test.js @@ -1,5 +1,7 @@ import * as bp from '../build-api-pages'; import fs from 'fs'; +import yaml from 'js-yaml'; +import $RefParser from '@apidevtools/json-schema-ref-parser'; describe(`updateMenu`, () => { @@ -2810,3 +2812,24 @@ describe(`schemaTable`, () => { }); }); + +describe(`getBestDiscriminant`, () => { + + let schemas; + beforeAll(async () => { + const spec = yaml.safeLoad(fs.readFileSync('./data/api/v1/full_spec.yaml', 'utf8')); + const deref = await $RefParser.dereference(spec, { resolve: { external: false } }); + schemas = deref.components.schemas; + }); + + it('discriminates WidgetDefinition on `type`', () => { + expect(bp.getBestDiscriminant(schemas.WidgetDefinition.oneOf)).toEqual('type'); + }); + + it('discriminates timeseries requests[].queries[] on `data_source`', () => { + const queries = schemas.TimeseriesWidgetRequest.properties.queries.items; + expect(queries).toBe(schemas.FormulaAndFunctionQueryDefinition); + expect(bp.getBestDiscriminant(queries.oneOf)).toEqual('data_source'); + }); + +});