Source: src/js/models/ontologies/BioontologyBatch.js

"use strict";

define(["backbone", "collections/ontologies/BioontologyResults"], (
  Backbone,
  BioontologyResults,
) => {
  /**
   * @class BioontologyBatch
   * @classdesc A model that fetches data from the BioPortal API using the batch
   * endpoint. This can be used to store data about classes that have been
   * fetched from BioPortal, and to fetch additional classes as needed.
   * @classcategory Models/Ontologies
   * @since 2.31.0
   * @augments Backbone.Model
   */
  const BioontologyBatch = Backbone.Model.extend({
    /** @lends BioontologyBatch.prototype */

    /**
     * The default attributes for this model. All attributes not documented here
     * are detailed on the BioPortal API docs:
     * https://data.bioontology.org/documentation.
     * @returns {object} The default attributes for this model
     * @property {Backbone.Collection} collection - The collection of classes
     * fetched from BioPortal
     * @property {string} apiKey - The API key to use for requests to BioPortal.
     * If not set, the appModel's API key will be used.
     * @property {string} apiBaseURL - The base URL for the BioPortal API.
     * @property {string} ontologyPrefix - A string to prepend to ontology
     * acronyms to form the full ontology ID for batch requests. Note that this
     * is not the same as the ontology URL, as the ID starts with http not
     * https.
     * @property {string[]} ontologies - The ontologies to search for classes
     * in, in order of priority. Only the acronyms are needed.
     * @property {string[]} include - The fields to include in the response.
     * @property {string[]} classesToFetch - The classes (classIds) to fetch
     * from BioPortal.
     */
    defaults() {
      return {
        collection: new BioontologyResults(),
        apiKey: null,
        apiBaseURL: MetacatUI.appModel.get("bioportalApiBaseUrl"),
        ontologyPrefix: "http://data.bioontology.org/ontologies/",
        ontologies: MetacatUI.appModel.get("bioportalOntologies"),
        include: ["prefLabel", "definition", "subClassOf", "hasChildren"],
        classesToFetch: [],
      };
    },

    /**
     * Initialize the Bioontology mode
     * @param {object} attributes - The model attributes
     * @param {string} attributes.apiKey - An alternative API key to use. If not
     * set, the appModel's API key will be used.
     * @param {object} _options - The options object
     */
    initialize(attributes, _options) {
      // Fall back to the appModel's API key if one is not provided
      if (!attributes?.apiKey && !this.get("apiKey")) {
        this.set("apiKey", MetacatUI.appModel.get("bioportalAPIKey"));
      }
    },

    /** @inheritdoc */
    url() {
      return `${this.get("apiBaseURL")}/batch`;
    },

    /**
     * Add classes from a response to the collection. This method is async and
     * will return a promise that resolves when the collection has been updated.
     * @param {object} response - The response from the BioPortal API
     * @param {string|object} [ontology] - Provide to include the ontology acronym
     * to store as an attribute on the class models
     * @returns {Promise<void>} A promise that resolves when the collection has
     * been updated
     */
    async addClassesFromResponse(response, ontology) {
      const collection = this.get("collection");
      let parsedResponse = Object.values(response).flat();
      const updated = new Promise((resolve) => {
        this.listenToOnce(this.get("collection"), "update", resolve);
      });
      if (ontology) {
        parsedResponse = parsedResponse.map((cls) => ({
          ...cls,
          ontology,
        }));
      }
      collection.add(parsedResponse, { parse: true });
      return updated;
    },

    /**
     * Create a payload for a batch request to the BioPortal API.
     * @param {string[]} classes - The classes to fetch
     * @param {string|object} ontology - The ontology acronym or object with
     * acronym stored in the "ontology" property
     * @returns {string} The JSON stringified payload
     */
    createBatchPayload(classes, ontology) {
      const acronym =
        typeof ontology === "object" ? ontology.ontology : ontology;
      const ontologyId = `${this.get("ontologyPrefix")}${acronym}`;
      const payload = {
        "http://www.w3.org/2002/07/owl#Class": {
          collection: classes.map((cls) => ({
            class: cls,
            ontology: ontologyId,
          })),
          display: this.get("include")?.join(",") || "prefLabel,definition",
        },
      };
      return JSON.stringify(payload);
    },

    /**
     * Create the headers for a request to the BioPortal API.
     * @returns {object} The headers object
     * @property {string} Content-Type - The content type of the request
     * @property {string} Authorization - The authorization header with the API
     * key
     */
    createHeaders() {
      return {
        "Content-Type": "application/json",
        Authorization: `apikey token=${this.get("apiKey")}`,
      };
    },

    /**
     * If some of the classes to fetch are already in the collection, remove
     * them from the list of classes to fetch.
     */
    filterClassesToFetch() {
      const classesToFetch = this.get("classesToFetch");
      const collection = this.get("collection");
      const existingClasses = collection.pluck("@id");
      this.set(
        "classesToFetch",
        classesToFetch.filter((cls) => !existingClasses.includes(cls)),
      );
    },

    /**
     * Make a batch request for a given set of classes and a single ontology.
     * @param {string[]} classes - The classes to fetch
     * @param {string} ontology - The ontology to search in
     * @returns {Promise<object>} A promise that resolves to the response from
     * the BioPortal API
     */
    async fetchClassesFromOntology(classes, ontology) {
      try {
        const payload = this.createBatchPayload(classes, ontology);
        const response = await fetch(this.url(), {
          method: "POST",
          headers: this.createHeaders(),
          body: payload,
        });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return await response.json();
      } catch (error) {
        this.recordError(error);
        return null;
      }
    },

    /**
     * Record an error that occurred during the fetch process.
     * @param {Error} error - The error that occurred
     */
    recordError(error) {
      const currentErrors = this.get("errors") || [];
      this.set("errors", [...currentErrors, error]);
    },

    /**
     * Move the classes that were not found to the list of classes not found. This
     * should be called after all classes have been fetched.
     */
    moveClassesToNotFound() {
      const classesNotFound = this.get("classesNotFound") || [];
      const leftOverClasses = this.get("classesToFetch") || [];
      this.set("classesNotFound", [...classesNotFound, ...leftOverClasses]);
      this.set("classesToFetch", []);
    },

    /**
     * Wait for the fetch process to complete. This will return a promise that
     * resolves when the fetch process is complete.
     * @returns {Promise<boolean>} A promise that resolves to true if the fetch
     * process is complete, and false if it is not complete
     */
    async waitForFetchComplete() {
      if (this.get("status") === "fetching") {
        await new Promise((resolve) => {
          this.listenToOnce(this, "fetchComplete", resolve);
        });
        return true;
      }
      return false;
    },

    /**
     * Initialize the fetch process. This will set the status to "fetching" and
     * set the list of classes to fetch to the provided classes.
     * @param {string[]} classes - The classes to fetch
     */
    initializeFetch(classes) {
      this.set("status", "fetching");
      const leftOverClasses = this.get("classesToFetch");
      this.set("classesNotFound", leftOverClasses);
      this.set("classesToFetch", classes);
      this.filterClassesToFetch();
    },

    /**
     * Fetch classes from the BioPortal API. This method is async and will
     * return a promise that resolves when the classes have been fetched.
     * @returns {Promise<object[]>} A promise that resolves to an array of
     * objects containing the classes fetched from BioPortal
     */
    async fetchFromOntologies() {
      const ontologies = this.get("ontologies");
      const responses = [];

      ontologies.forEach(async (ontology) => {
        const classesToFetch = this.get("classesToFetch");
        if (!classesToFetch.length) {
          return;
        }

        const response = await this.fetchClassesFromOntology(
          classesToFetch,
          ontology,
        ).catch((error) => {
          this.recordError(error);
          return null;
        });
        if (response) {
          responses.push(response);
          await this.addClassesFromResponse(
            response,
            ontology.label || ontology,
          );
          // Update the list of classes to fetch based on what was found
          this.filterClassesToFetch();
        }
      });

      return responses;
    },

    /**
     * Finalize the fetch process. This will set the status to "fetched" and
     * trigger the "fetchComplete" event.
     */
    finalizeFetch() {
      this.moveClassesToNotFound();
      this.set("status", "fetched");
      this.trigger("fetchComplete");
    },

    /**
     * Fetch classes from the BioPortal API. This method is async and will
     * return a promise that resolves when the classes have been fetched.
     * @param {string[]} classes - The classes to fetch
     * @returns {Promise<Backbone.Model[]>} A promise that resolves to an array
     * of Backbone models
     */
    async fetchClasses(classes) {
      try {
        if (await this.waitForFetchComplete()) {
          return this.fetchClasses(classes);
        }
        this.initializeFetch(classes);
        const responses = await this.fetchFromOntologies();
        return responses.flatMap((response) =>
          response ? response.classes : [],
        );
      } catch (error) {
        this.recordError(error);
        return [];
      } finally {
        this.finalizeFetch();
      }
    },

    /**
     * Gets the models for given classes. For classes that exist already, the
     * model will be fetched from the collection. For classes that do not exist
     * yet, the bioportal API will be queried. The promise will resolve when all
     * models have been fetched.
     * @param {string[]} classes - The classes to fetch
     * @returns {Promise<Backbone.Model[]>} A promise that resolves to an array
     * of Backbone models
     */
    async getClasses(classes) {
      const existingClasses = this.getCachedClasses(classes);
      const newClasses = await this.fetchClasses(classes);
      return [...existingClasses, ...newClasses];
    },

    /**
     * Get the models for classes that have already been fetched from BioPortal.
     * @param {string[]} classes - The class IDs to get models for
     * @returns {Backbone.Model[]} The models for the classes that have already
     * been fetched
     */
    getCachedClasses(classes) {
      const collection = this.get("collection");
      collection.restoreFromCache(classes);
      const models = collection.filter((model) => classes.includes(model.id));
      return models;
    },
  });

  return BioontologyBatch;
});