Source: form/json-schema/inPlaceApplicators.js

/**
 * Module that analyzes and resolves [JSON Schema in-place applicators](https://json-schema.org/draft/2019-09/json-schema-core.html#in-place).
 *
 * In this approach, the in-place applicators present in a JSON Schema are
 * modeled as a directed tree (where nodes represented by {@link
 * module:inPlaceApplicators~IPASchema} objects). Each node stores the keywords
 * belonging to its corresponding subschema: hence, any possible materialization
 * of a JSON Schema can be reconstructed by aggregating the keywords of each
 * node traversed by any directed path from the root node to the leaf.
 *
 * The user is intended to select the desired path: {@link module:selectors}
 * provides a user interface for this purpose.
 *
 * @module inPlaceApplicators
 */
import JsonSchemaKeywords from './JsonSchemaKeywords.js';

/**
 * Processes a JSON Schema, generating an in-place application tree that
 * represents its possible materializations.
 *
 * @param {object} schema The JSON Schema to process.
 * @param {string} [pointer=''] A JSON Pointer that provides unique
 * identification to each in-place applicator in the JSON Schema.
 * @param {number} [selected=0] The index indicating the applicator subschema to
 * be selected at initialization.
 *
 * @returns {module:inPlaceApplicators~IPASchema} The root node to the in-place
 * application tree that represents the given JSON Schema.
 */
function process(schema, pointer = '', selected = 0) {
  const { common, applicatorByPointer } = schema.allOf
    ? processAllOf(
        schema,
        `${pointer}/${JsonSchemaKeywords.BooleanLogicApplicator.ALL_OF}`
      )
    : {
        common: {
          ...schema,
        },
        applicatorByPointer: new Map(),
      };

  if (schema.anyOf) {
    delete common.anyOf;
    const aop = `${pointer}/${JsonSchemaKeywords.BooleanLogicApplicator.ANY_OF}`;

    applicatorByPointer.set(
      aop,
      new InPlaceApplicator(processAnyOf(schema.anyOf, aop), selected)
    );
  }

  if (schema.oneOf) {
    delete common.oneOf;
    const oop = `${pointer}/${JsonSchemaKeywords.BooleanLogicApplicator.ONE_OF}`;

    applicatorByPointer.set(
      oop,
      new InPlaceApplicator(processOneOf(schema.oneOf, oop), selected)
    );
  }

  return new IPASchema(common, applicatorByPointer);
}

/**
 * @typedef {object} ProcessAllOfResult
 *
 * @property {object} common The keywords that are common to all the `allOf`
 * subschemas.
 * @property {Map.<string, module:inPlaceApplicators~InPlaceApplicator>} applicatorByPointer
 * A map including the child in-place applicators contained by the processed
 * `allOf` applicator.
 */

/**
 * Processes an `allOf` in-place applicator.
 *
 * @param {object} schema The JSON Schema including an `allOf` property to
 * process.
 * @param {string} [pointer=''] A JSON Pointer that identifies the JSON Schema.
 *
 * @returns {module:inPlaceApplicators~ProcessAllOfResult} The result of
 * processing the `allOf` in-place applicator.
 */
function processAllOf(schema, pointer) {
  const ipaSchemas = schema.allOf.map((s, i) => process(s, `${pointer}/${i}`));

  return {
    common: aggregate(schema, ...ipaSchemas),
    applicatorByPointer: new Map(
      ipaSchemas.flatMap((ipaSchema) => [...ipaSchema.applicatorByPointer])
    ),
  };
}

/**
 * Processes an `anyOf` in-place applicator.
 *
 * @param {Array.<object>} subschemas The subschemas included in the `anyOf`
 * in-place applicator.
 * @param {string} [pointer=''] A JSON Pointer that identifies the JSON Schema.
 *
 * @returns {Array.<module:inPlaceApplicators~IPASchema>} An array of nodes
 * representing each subschema contained by the `anyOf` applicator.
 */
function processAnyOf(subschemas, pointer) {
  return processDisjunctiveInPlaceApplicator(subschemas, pointer);
}

/**
 * Processes an `oneOf` in-place applicator.
 *
 * @param {Array.<object>} subschemas The subschemas included in the `oneOf`
 * in-place applicator.
 * @param {string} [pointer=''] A JSON Pointer that identifies the JSON Schema.
 *
 * @returns {Array.<module:inPlaceApplicators~IPASchema>} An array of nodes
 * representing each subschema contained by the `oneOf` applicator.
 */
function processOneOf(subschemas, pointer) {
  return processDisjunctiveInPlaceApplicator(subschemas, pointer);
}

/**
 * Processes a disjunctive in-place applicator.
 *
 * @param {Array.<object>} subschemas The subschemas included in the disjunctive
 * in-place applicator.
 * @param {string} [pointer=''] A JSON Pointer that identifies the JSON Schema.
 *
 * @returns {Array.<module:inPlaceApplicators~IPASchema>} An array of nodes
 * representing each subschema contained by the applicator.
 */
function processDisjunctiveInPlaceApplicator(subschemas, pointer) {
  return subschemas.map((s, i) => process(s, `${pointer}/${i}`));
}

/** Class representing a JSON Schema in-place applicator. */
class InPlaceApplicator {
  /**
   * Class constructor.
   *
   * @param {Array.<module:inPlaceApplicators~IPASchema>} subschemas The array
   * of nodes representing the subschemas contained by the applicator.
   * @param {number} selected The index indicating the subschema to be selected
   * at initialization.
   */
  constructor(subschemas, selected) {
    /**
     * The array of nodes representing the subschemas contained by the
     * applicator.
     *
     * @type {Array.<module:inPlaceApplicators~IPASchema>}
     */
    this.subschemas = subschemas;

    /**
     * The index indicating the selected subschema.
     *
     * @type {number}
     */
    this.selected = selected;
  }

  /**
   * Retrieves the node representing the in-place applicator subschema that is
   * currently selected.
   *
   * @returns {module:inPlaceApplicators~IPASchema} The selected node
   * representing an in-place applicator subschema.
   */
  getSelectedIpaSchema() {
    return this.subschemas[this.selected];
  }
}

/**
 * The node for a tree representation of the possible JSON Schema
 * materializations regarding its in-place applicators (standing for "In-Place
 * Application Schema").
 */
class IPASchema {
  /**
   * Class constructor.
   *
   * @param {object} common The keywords that are common to all possible
   * materializations of the in-place application subtree.
   * @param {Map.<string, module:inPlaceApplicators~InPlaceApplicator>} applicatorByPointer
   * A map including the child in-place applicators to be contained by the
   * node.
   */
  constructor(common, applicatorByPointer) {
    /**
     * The keywords that are common to all possible materializations of the
     * in-place application subtree.
     *
     * @type {object}
     */
    this.common = common;

    /**
     * A map including the child in-place applicators contained by the node.
     *
     * @type {Map.<string, module:inPlaceApplicators~InPlaceApplicator>}
     */
    this.applicatorByPointer = applicatorByPointer;
  }

  /**
   * Returns the nodes representing the selected in-place applicator subschemas.
   *
   * @returns {Array.<module:inPlaceApplicators~IPASchema>?} The array of
   * selected in-place applicator subnodes, or `null` if the node has no child
   * in-place applicators.
   */
  getSelectedApplicators() {
    if (this.applicatorByPointer.size)
      return Array.from(this.applicatorByPointer).map(([, a]) =>
        a.getSelectedIpaSchema()
      );
    else return null;
  }

  /**
   * Retrieves the materialization of the JSON Schema that is currently
   * selected.
   *
   * @returns {object} The selected materialization of the JSON Schema
   * represented by the node.
   */
  getSelectedSchema() {
    const recursion = (ipaSchema) => {
      if (ipaSchema.applicatorByPointer.size == 0) return ipaSchema;
      else
        return new IPASchema(
          aggregate(
            ipaSchema.common,
            ...Array.from(ipaSchema.applicatorByPointer).map(([, a]) =>
              recursion(a.getSelectedIpaSchema())
            )
          )
        );
    };

    return recursion(this).common;
  }

  /**
   * Retrieves the [annotation-related keywords](https://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.9)
   * from the in-place application node.
   *
   * @returns {object} The JSON Schema subset representing the annotations at
   * the top level.
   */
  getAnnotations() {
    const annotations = {};

    if (this.common.title) annotations.title = this.common.title;

    if (this.common.description)
      annotations.description = this.common.description;

    return annotations;
  }
}

/**
 * Aggregates the keywords of a JSON Schema with the common keywords from a list
 * of in-place application nodes.
 *
 * Collisions are resolved applying the logic implemented by the
 * {@link module:inPlaceApplicators~Aggregation} class.
 *
 * @param {object} schema The JSON Schema to aggregate.
 * @param {...module:inPlaceApplicators~IPASchema} ipaSchemas The in-place
 * application nodes whose common keywords are to be aggregated.
 *
 * @returns {object} The aggregated JSON Schema.
 */
function aggregate(schema, ...ipaSchemas) {
  const common = {
    ...schema,
  };

  delete common.allOf;

  for (const ipaSchema of ipaSchemas) {
    const filteredKeywords = Object.entries(ipaSchema.common).filter(
      ([keyword]) =>
        !Object.values(JsonSchemaKeywords.Annotation).includes(keyword)
    );

    for (const [keyword, value] of filteredKeywords) {
      if (Object.prototype.hasOwnProperty.call(common, keyword))
        if (common[keyword] instanceof Aggregation) common[keyword].add(value);
        else common[keyword] = new Aggregation(keyword, common[keyword], value);
      else common[keyword] = value;
    }
  }

  for (const [keyword, value] of Object.entries(common))
    if (value instanceof Aggregation) common[keyword] = value.resolve();

  return common;
}

/** Class that handles keyword collision during aggregation process. */
class Aggregation {
  /**
   * Class constructor.
   *
   * @param {string} keyword The aggregated JSON Schema keyword.
   * @param {...any} init The initial values to aggregate.
   */
  constructor(keyword, ...init) {
    this.keyword = keyword;
    this.values = init.map((i) => (i instanceof Aggregation ? i.values : i));
  }

  /**
   * Adds a value to the aggregation, or joins the content of another
   * `Aggregation` object.
   *
   * @param {any} e The value/aggregation to be added/joined.
   */
  add(e) {
    if (e instanceof Aggregation) this.values.concat(e.values);
    else this.values.push(e);
  }

  /**
   * Generates an aggregated value following the specific rules associated to
   * the keyword.
   *
   * @returns {any} The value resulted of applying the aggregation rules to the
   * different contained values.
   *
   * @todo Implement specific logic for every keyword.
   */
  resolve() {
    const resolveSwitch = {
      [JsonSchemaKeywords.ObjectApplicator.PROPERTIES]: () =>
        Object.assign({}, ...this.values),
      [JsonSchemaKeywords.ObjectValidation.REQUIRED]: () => this.values.flat(),
      default: () =>
        console.warn(`Keyword aggregation not defined for the\
 "${this.keyword}" keyword.`),
    };

    return (resolveSwitch[this.keyword] || resolveSwitch.default)();
  }
}

export default process;
export { InPlaceApplicator, IPASchema };