Source: src/js/models/dataONEServices/DataONEService.js

define([
  "md5",
  "models/dataONEServices/DataONEHttpClient",
  "models/PersistentStorage",
  "common/Utilities",
], (md5, DataONEHttpClient, PersistentStorage, Utilities) => {
  /**
   * Extensible base class for DataONE API services, e.g. the System Metadata
   * service. It includes in-memory and persistent caching scoped by user
   * authentication, deduplication of in-flight requests handled by the
   * DataONEHttpClient, and utility methods for making requests. This class
   * always uses singleton instances of DataONEHttpClient and PersistentStorage
   * to ensure that deduplication and caching work properly across all service
   * instances with the same configuration.
   * @class DataONEService
   * @since 2.37.0
   * @classcategory Models/DataONEServices
   */
  class DataONEService {
    /**
     * @param {object} [options] Options for the service instance.
     * @param {string} options.baseUrl Base URL for the DataONE endpoint, e.g.
     * https://cn.dataone.org/cn/v2
     * @param {DataONEHttpClient#DataONEHttpClientOptions} [options.clientConfig]
     * DataONEHttpClient options
     * @param {PersistentStorage#PersistentStorageOptions} [options.storageConfig]
     * Storage options, plus DataONEService namespace
     * fields (e.g. `domain`, `endpoint`, `app`, `scope`, `baseUrl`).
     * @param {boolean} [options.persistPrivate] Whether or not to persist
     * private (authenticated) data in storage. Defaults to false.
     * @param {Function} [options.getToken] A function that returns a Promise
     * that resolves to an auth token string.
     * @param {Function} [options.getUserName] A function that returns a Promise
     * that resolves to the current username string.
     * @param {boolean} [options.defaultAuth] Whether or not to send requests
     * with authorization by default, when no auth option is provided, and when
     * a user token is available. Defaults to true.
     */
    constructor(options) {
      const sourceOptions = options || {};
      const normalized = this.constructor.normalizeOptions(sourceOptions);

      this.persistPrivate = normalized.persistPrivate;
      this.defaultAuth = normalized.defaultAuth;
      this.client = DataONEHttpClient.get(normalized.clientConfig);
      this.storageConfig = normalized.storageConfig;

      // functions get lost during normalization, use originals
      if (typeof sourceOptions.getToken === "function") {
        this.getToken = sourceOptions.getToken;
      }
      if (typeof sourceOptions.getUserName === "function") {
        this.getUserName = sourceOptions.getUserName;
      }
    }

    /**
     * Normalize the options used to construct a new instance.
     * @param {object} options Options paseed to the constructor.
     * @returns {object} Normalized options.
     */
    static normalizeOptions(options = {}) {
      const normalized = JSON.parse(JSON.stringify(options));
      if (!normalized.baseUrl) {
        throw new Error("DataONEService: baseUrl is required");
      }
      normalized.baseUrl = Utilities.normalizeUrl(normalized.baseUrl);

      normalized.clientConfig = {
        ...normalized.clientConfig,
        baseUrl: normalized.baseUrl,
      };

      normalized.storageConfig = normalized.storageConfig || {};
      if (!normalized.storageConfig.instanceKeys?.length) {
        normalized.storageConfig.instanceKeys = [];
        normalized.storageConfig.instanceKeys.push(normalized.baseUrl);
      }

      // if persistPrivate is not a boolean, default to false
      normalized.persistPrivate =
        typeof normalized.persistPrivate === "boolean"
          ? normalized.persistPrivate
          : false;

      // if defaultAuth is not a boolean, default to true
      normalized.defaultAuth =
        typeof normalized.defaultAuth === "boolean"
          ? normalized.defaultAuth
          : true;
      return normalized;
    }

    /**
     * If user is logged in, get a key based on their username; otherwise,
     * return a "public" scope key.
     * @returns {Promise<string>} Promise resolving to the scope key.
     */
    async scopeKey() {
      const userName = await this.getUserName();
      return `auth:${md5(userName || "public")}`;
    }

    /**
     * Get a PersistentStorage instance automatically scoped by the logged in
     * user, using the configuration options provided to the constructor.
     * @returns {PersistentStorage} Storage instance.
     */
    async getStore() {
      const scopeKey = await this.scopeKey();
      const options = JSON.parse(JSON.stringify(this.storageConfig));
      options.instanceKeys = options.instanceKeys || [];
      options.instanceKeys.push(scopeKey);
      return PersistentStorage.get(options);
    }

    /**
     * Wait for the MetacatUI app user model to be available, and return it.
     * @returns {Promise<Backbone.Model>} Promise resolving to the user model.
     */
    static async awaitUserModel() {
      return Utilities.awaitMetacatUI({ property: "appUserModel" });
    }

    /**
     * Resolve the current username from the MetacatUI app user model.
     * @returns {Promise<string|null>} Promise resolving to the username, or
     * null if unavailable.
     */
    async getUserName() {
      const userModel = await this.constructor.awaitUserModel();
      let userName = userModel.get("username");
      if (!userName) {
        try {
          await this.getToken(); // Parses token and sets username
          // small pause to ensure parsing is complete and username is set
          await new Promise((resolve) => {
            setTimeout(resolve, 10);
          });
          userName = userModel.get("username");
        } catch (e) {
          // eslint-disable-next-line no-console
          console.warn(
            "Failed to get username from token, assuming anonymous user.",
            e,
          );
        }
      }
      return userName || null;
    }

    /**
     * Resolve an auth token from MetacatUI when available.
     * @returns {Promise<string>} Promise resolving to an auth token.
     */
    async getToken() {
      const userModel = await this.constructor.awaitUserModel();
      let token = userModel.get("token");
      if (!token && userModel.get("tokenChecked") !== true) {
        try {
          token = await userModel.getTokenPromise();
        } catch (e) {
          // eslint-disable-next-line no-console
          console.warn("Failed to get token from MetacatUI user model.", e);
        }
      }
      return token || null;
    }

    /**
     * Determine the token to use, if any, for a request.
     * @param {boolean} [auth] Whether to use authentication for this request.
     * If set to false, no token will be used. If true, getToken() will be
     * called. When undefined, defaultAuth is used.
     * @returns {Promise<string|null>} Promise resolving to the token, or null
     * if no auth.
     */
    async resolveToken(auth) {
      // If auth is explicitly set to false, don't use a token
      const useAuth = typeof auth === "boolean" ? auth : this.defaultAuth;
      if (!useAuth) return null;
      // If auth is true (or defaultAuth is true), resolve the token
      // automatically. This will return null if user is not logged in.
      return this.getToken();
    }

    /**
     * Use the path as the cache key by default, but allow an override if
     * desired.
     * @param {string} path Path relative to baseUrl.
     * @param {string|null|undefined} [cacheKey] Cache key override.
     * @returns {string} Resolved cache key.
     */
    static resolveCacheKey(path, cacheKey) {
      return cacheKey !== undefined && cacheKey !== null ? cacheKey : path;
    }

    /**
     * Determine whether caching is allowed for the current user scope.
     * @returns {Promise<boolean>} Promise resolving to whether caching is
     * allowed.
     */
    async shouldUseCache() {
      // If we always persist private data, caching is allowed in all cases
      if (this.persistPrivate) {
        return true;
      }
      // otherwise, if user is not logged in (public data) allow caching
      const user = await this.getUserName();
      return !user;
    }

    /**
     * Send a request via the DataONEHttpClient.
     * @param {object} [options] Request options passed to
     * {@link DataONEHttpClient#request}, plus auth controls.
     * @param {string} options.path Path relative to baseUrl.
     * @param {boolean} [options.auth] Whether to resolve a token automatically.
     * @returns {Promise<DataONEHttpResponse>} Promise resolving to the
     * response.
     */
    async request(options = {}) {
      const { auth, ...clientOptions } = options;
      const resolvedToken = await this.resolveToken(auth);
      return this.client.request({
        ...clientOptions,
        token: resolvedToken,
      });
    }

    /**
     * Download data from a path with optional caching.
     * @param {string} path Path relative to baseUrl.
     * @param {object} [options] Options passed to
     * {@link DataONEHttpClient#request} (except `path`), plus cache controls.
     * @param {boolean} [options.auth] Whether to resolve a token automatically.
     * @param {boolean} [options.useCache] Whether to cache responses, default
     * true.
     * @param {string} [options.cacheKey] Cache key override.
     * @param {number|null} [options.cacheTtlMs] Cache TTL override.
     * @returns {Promise<*>} Promise resolving to response data.
     */
    async download(path, options = {}) {
      const {
        useCache = true,
        cacheKey,
        cacheTtlMs,
        ...clientOptions
      } = options;

      const resolvedCacheKey = this.constructor.resolveCacheKey(path, cacheKey);
      const cacheAllowed = useCache !== false;

      if (cacheAllowed) {
        const cached = await this.getCached(resolvedCacheKey);
        if (cached !== null && cached !== undefined) {
          return cached;
        }
      }

      const response = await this.request({
        ...clientOptions,
        path,
        method: clientOptions.method || "GET",
      });

      if (cacheAllowed) {
        await this.setCached(resolvedCacheKey, response.data, {
          ttlMs: cacheTtlMs,
        });
      }

      return response.data;
    }

    /**
     * Upload data to a path.
     * @param {string} path Path relative to baseUrl.
     * @param {object} [options] Options passed to
     * {@link DataONEHttpClient#request} (except `path`), plus cache controls.
     * @param {boolean} [options.auth] Whether to resolve a token automatically.
     * @param {string} [options.cacheKey] Cache key override.
     * @returns {Promise<DataONEHttpResponse>} Promise resolving to the
     * response.
     */
    async upload(path, options = {}) {
      const { cacheKey, ...clientOptions } = options;

      const response = await this.request({
        ...clientOptions,
        path,
        method: clientOptions.method || "PUT",
      });

      // If successful uploaded, update the cache with the new data
      const resolvedCacheKey = this.constructor.resolveCacheKey(path, cacheKey);
      if (options.useCache !== false) {
        await this.setCached(resolvedCacheKey, clientOptions.body);
      }

      return response;
    }

    /**
     * Get a cached value scoped by the current user.
     * @param {string} key Cache key.
     * @returns {Promise<*>} Promise resolving to the cached value.
     */
    async getCached(key) {
      if (!key) return null;
      if (!(await this.shouldUseCache())) return null;
      const store = await this.getStore();
      const found = await store.getItem(key);
      return found;
    }

    /**
     * Check whether a value exists in the cache for the current user scope.
     * @param {string} key Cache key.
     * @returns {Promise<boolean>} Promise resolving to whether the value is
     * cached.
     */
    async isCached(key) {
      const value = await this.getCached(key);
      return value !== null && value !== undefined;
    }

    /**
     * Store a value in the cache for the current user scope.
     * @param {string} key Cache key.
     * @param {*} value Value to store.
     * @param {object} [options] Cache scope options.
     * @param {number|null} [options.ttlMs] Override cache TTL in ms.
     * @returns {Promise<*>} Promise resolving to the stored value.
     */
    async setCached(key, value, { ttlMs } = {}) {
      if (!key) return value;
      if (!(await this.shouldUseCache())) return value;
      const store = await this.getStore();
      await store.setItem(key, value, { ttlMs });
      return value;
    }

    /**
     * Remove a cached value for the current user scope.
     * @param {string} key Cache key.
     * @returns {Promise<void>} Promise resolving when removal completes.
     */
    async removeCached(key) {
      if (!key) return;
      const store = await this.getStore();
      await store.removeItem(key);
    }

    /**
     * Clear the cache for the current user scope.
     * @returns {Promise<void>} Promise resolving when the cache is cleared.
     */
    async clearCache() {
      const store = await this.getStore();
      await store.clear();
    }
  }

  return DataONEService;
});