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

define([
  "models/dataONEServices/DataONEService",
  "models/dataONEServices/DataONEHttpClient",
  "models/dataONEServices/ObjectLocationResolver",
  "common/ErrorUtilities",
  "common/UrlUtilities",
  "common/ValueUtilities",
], (
  DataONEService,
  DataONEHttpClient,
  ObjectLocationResolver,
  ErrorUtilities,
  UrlUtilities,
  ValueUtilities,
) => {
  /**
   * Default DataONEHttpClient options for ObjectService reads.
   * @type {DataONEHttpClient#DataONEHttpClientOptions}
   */
  const DEFAULT_READ_CLIENT_OPTIONS = {
    timeoutMs: null,
    allowedHttpMethods: ["GET"],
    headerNamesForDedup: ["Authorization", "Accept"],
    responseTypes: ["json", "arrayBuffer", "blob", "text"],
  };

  /**
   * Default DataONEHttpClient options for ObjectService writes.
   *
   * Retries are disabled by default because create/update are non-idempotent:
   * a POST/PUT that committed on the server but then surfaced a retryable
   * status (e.g. a 502/504 from an intermediary still processing the request,
   * or a client-side timeout) would otherwise be replayed, producing a
   * duplicate object or a committed-but-reported-as-failed write. Callers that
   * need bounded retries for a specific, safe-to-replay operation can opt in
   * via a per-request `retry` override.
   * @type {DataONEHttpClient#DataONEHttpClientOptions}
   */
  const DEFAULT_WRITE_CLIENT_OPTIONS = {
    timeoutMs: null,
    allowedHttpMethods: ["GET", "POST", "PUT"],
    headerNamesForDedup: ["Authorization", "Content-Type", "Accept"],
    responseTypes: ["json", "arrayBuffer", "blob", "text"],
    retry: {
      maxRetries: 0,
      retryOn: [],
      retryNetworkErrors: false,
    },
  };

  /**
   * Service for DataONE object read/download/create/update operations.
   * @class ObjectService
   * @augments DataONEService
   * @classcategory Models/DataONEServices
   * @since 2.39.0
   */
  class ObjectService extends DataONEService {
    /**
     * @param {object} [options] Options for the ObjectService
     * @param {string} [options.readBaseUrl] Base URL for object reads
     * @param {string} [options.writeBaseUrl] Base URL for object writes
     * @param {string} [options.resolveServiceUrl] CN resolve service URL
     * @param {string} [options.metaServiceUrl] System Metadata service URL
     * @param {ObjectLocationResolver} [options.locationResolver] Object
     * location resolver
     * @param {DataONEHttpClient#DataONEHttpClientOptions} [options.clientConfig]
     * DataONEHttpClient configuration
     * @param {boolean} [options.defaultAuth] Default auth behavior
     * @param {Function} [options.getToken] Override token resolver function
     * @throws {Error} When readBaseUrl is missing
     */
    constructor({
      readBaseUrl = "",
      writeBaseUrl = "",
      resolveServiceUrl = "",
      metaServiceUrl = "",
      locationResolver,
      clientConfig = {},
      defaultAuth,
      getToken,
    } = {}) {
      const normalizedReadBaseUrl = UrlUtilities.normalizeUrl(readBaseUrl);
      if (!normalizedReadBaseUrl) {
        throw new Error("ObjectService: readBaseUrl is required");
      }

      const resolvedDefaultAuth =
        typeof defaultAuth === "boolean" ? defaultAuth : true;
      const readClientConfig = ObjectService.buildClientConfig({
        defaults: DEFAULT_READ_CLIENT_OPTIONS,
        overrides: clientConfig,
        baseUrl: normalizedReadBaseUrl,
      });

      super({
        baseUrl: normalizedReadBaseUrl,
        clientConfig: readClientConfig,
        defaultAuth: resolvedDefaultAuth,
        getToken,
      });

      this.readBaseUrl = normalizedReadBaseUrl;
      this.readClientConfig = { ...readClientConfig, baseUrl: "" };
      this.writeBaseUrl = UrlUtilities.normalizeUrl(writeBaseUrl);
      this.resolveServiceUrl = UrlUtilities.normalizeUrl(resolveServiceUrl);
      this.locationResolver =
        normalizedReadBaseUrl === this.resolveServiceUrl
          ? locationResolver ||
            new ObjectLocationResolver({ metaServiceUrl, getToken })
          : null;
      this.writeClientConfig = ObjectService.buildClientConfig({
        defaults: DEFAULT_WRITE_CLIENT_OPTIONS,
        overrides: clientConfig,
        baseUrl: "",
      });
    }

    /**
     * Build the exact request URL used to read an object.
     * @param {string} pid PID to read.
     * @returns {string} Full object request URL.
     */
    getReadUrl(pid) {
      return UrlUtilities.buildUrl(
        this.readBaseUrl,
        this.constructor.buildPidPath(pid),
        { encodePath: false },
      );
    }

    /**
     * Append object content to multipart form data.
     * @param {FormData} formData FormData instance.
     * @param {*} objectBody Object content.
     * @param {string} [fileName] Optional file name.
     */
    static appendObjectBody(formData, objectBody, fileName) {
      const hasFileName =
        ValueUtilities.isNonEmptyString(fileName) && objectBody instanceof Blob;

      if (hasFileName) {
        formData.append("object", objectBody, fileName);
      } else {
        formData.append("object", objectBody);
      }
    }

    /**
     * Build the multipart form data for create/update requests.
     * @param {object} params Transfer params.
     * @param {string} params.pid Existing or new PID.
     * @param {string} [params.newPid] Replacement PID for updates.
     * @param {*} params.object Object payload.
     * @param {string} params.sysMetaXml System metadata XML.
     * @param {string} [params.fileName] Optional filename.
     * @returns {FormData} Multipart form data.
     */
    static buildTransferFormData({
      pid,
      newPid,
      object,
      sysMetaXml,
      fileName,
    }) {
      const formData = new FormData();
      formData.append("pid", pid);
      if (newPid !== undefined && newPid !== null) {
        formData.append("newPid", newPid);
      }
      const sysMetaBlob = new Blob([sysMetaXml], { type: "application/xml" });
      formData.append("sysmeta", sysMetaBlob, "sysmeta");
      this.appendObjectBody(formData, object, fileName);
      return formData;
    }

    /**
     * Build request options for create/update transfer requests.
     * @param {string} path Request path.
     * @param {"POST"|"PUT"} method HTTP method.
     * @param {FormData} body Multipart payload.
     * @param {object} [options] Transfer options.
     * @returns {object} Normalized request options.
     */
    buildTransferRequest(path, method, body, options = {}) {
      const transport =
        options.transport === undefined ? "fetch" : options.transport;

      return this.constructor.buildRequestOptions({
        options,
        path,
        method,
        body,
        accept: "text/xml",
        dedupe: false,
        extra: { transport },
      });
    }

    /**
     * Get a read client for a selected object service URL.
     * @param {string} baseUrl Object service base URL
     * @returns {DataONEHttpClient} Read client instance
     * @since 2.39.0
     */
    getReadClient(baseUrl) {
      return DataONEHttpClient.get({
        ...this.readClientConfig,
        baseUrl,
      });
    }

    /**
     * Get the write client for create/update requests.
     * @param {string} operation Operation name for error reporting.
     * @returns {DataONEHttpClient} Write client instance.
     * @throws {Error} When writeBaseUrl is missing
     */
    getWriteClient(operation) {
      if (!this.writeBaseUrl) {
        throw new Error(
          `ObjectService: writeBaseUrl is required for ${operation}`,
        );
      }
      return DataONEHttpClient.get({
        ...this.writeClientConfig,
        baseUrl: this.writeBaseUrl,
      });
    }

    /**
     * Execute a create/update transfer request and normalize the XML response.
     * @param {string} operation Operation name for error reporting.
     * @param {string} path Request path.
     * @param {"POST"|"PUT"} method HTTP method.
     * @param {FormData} body Multipart payload.
     * @param {string} context Error/parse context.
     * @param {object} [options] Transfer options.
     * @returns {Promise<DataONEHttpResponse>} Parsed identifier response.
     */
    async sendTransferRequest(
      operation,
      path,
      method,
      body,
      context,
      options = {},
    ) {
      return this.sendParsedIdentifierRequest({
        client: this.getWriteClient(operation),
        requestOptions: this.buildTransferRequest(path, method, body, options),
        context,
      });
    }

    /**
     * Fetch object content and return the full normalized response.
     * @param {string} pid PID to fetch.
     * @param {object} [options] Request options for
     * {@link DataONEService#request}.
     * @returns {Promise<DataONEHttpResponse>} Full response object.
     */
    async fetch(pid, options = {}) {
      const normalizedPid = this.constructor.normalizePid(pid);
      const { responseType = "blob", auth, ...requestOptions } = options;
      const builtRequest = this.constructor.buildRequestOptions({
        options: requestOptions,
        path: this.constructor.buildPidPath(normalizedPid),
        method: "GET",
        responseType,
      });
      const token = await this.resolveToken(auth);

      if (!token || !this.locationResolver) {
        return this.client.request({ ...builtRequest, token });
      }

      const location = await this.locationResolver.locate(normalizedPid, {
        signal: requestOptions.signal,
      });
      if (!location.objectServiceUrls.length) {
        if (location.isPublic) {
          try {
            return await this.client.request({ ...builtRequest, token: null });
          } catch (error) {
            const status = Number(error?.status) || null;
            if (status !== 401 && status !== 403) throw error;
            throw ErrorUtilities.createNamedError(
              "ObjectTransportError",
              `The public object could not be read through the resolve service: ${normalizedPid}.`,
              { code: "OBJECT_TRANSPORT_UNAVAILABLE", status: null },
            );
          }
        }
        throw ErrorUtilities.createNamedError(
          "ObjectLocationError",
          `No registered readable Member Node was found for ${normalizedPid}.`,
          { code: ObjectLocationResolver.LOCATION_ERROR_CODE },
        );
      }

      return this.fetchFromLocations(
        location.objectServiceUrls,
        builtRequest,
        token,
      );
    }

    /**
     * Fetch an object from its registered locations.
     * @param {string[]} objectServiceUrls Registered object service URLs
     * @param {object} request Built request options
     * @param {string} token Bearer token
     * @returns {Promise<DataONEHttpResponse>} Full response object
     * @since 2.39.0
     */
    fetchFromLocations(objectServiceUrls, request, token) {
      const attempts = [];

      const tryLocation = (index) => {
        if (index >= objectServiceUrls.length) {
          const allMissing = attempts.every(
            ({ status }) => status === 404 || status === 410,
          );
          return Promise.reject(
            ErrorUtilities.createNamedError(
              allMissing ? "ObjectNotFoundError" : "ObjectTransportError",
              allMissing
                ? "The object was not found at any registered location."
                : "No registered object location could be contacted.",
              {
                code: allMissing
                  ? "OBJECT_NOT_FOUND"
                  : "OBJECT_TRANSPORT_UNAVAILABLE",
                status: allMissing ? 404 : null,
                attempts,
              },
            ),
          );
        }

        const objectServiceUrl = objectServiceUrls[index];
        return this.getReadClient(objectServiceUrl)
          .request({
            ...request,
            token,
            redirect: "error",
            transport: "fetch",
          })
          .catch((error) => {
            if (ErrorUtilities.isAbortError(error)) throw error;
            const status = Number(error?.status) || null;
            attempts.push({
              objectServiceUrl,
              status,
              code: error?.code || null,
            });
            if (status === 401 || status === 403) throw error;
            const retryAtNextLocation =
              error?.networkError === true ||
              ErrorUtilities.isTimeoutError(error) ||
              status === 404 ||
              status === 410 ||
              status >= 500;
            if (!retryAtNextLocation) throw error;
            return tryLocation(index + 1);
          });
      };

      return tryLocation(0);
    }

    /**
     * Download object content and return only the response data.
     * @param {string} pid PID to download.
     * @param {object} [options] Request options for {@link ObjectService#fetch}.
     * @returns {Promise<*>} Response payload.
     */
    async download(pid, options = {}) {
      const response = await this.fetch(pid, options);
      return response.data;
    }

    /**
     * Download object content from the repository that accepts writes.
     * @param {string} pid PID to download
     * @param {object} [options] Request options
     * @returns {Promise<*>} Response payload
     */
    async downloadFromWriteTarget(pid, options = {}) {
      const normalizedPid = this.constructor.normalizePid(pid);
      const { responseType = "blob", ...requestOptions } = options;
      const response = await this.requestWithClient(
        this.getWriteClient("downloadFromWriteTarget"),
        this.constructor.buildRequestOptions({
          options: requestOptions,
          path: this.constructor.buildPidPath(normalizedPid),
          method: "GET",
          responseType,
        }),
      );
      return response.data;
    }

    /**
     * Create a new DataONE object.
     * @param {object} params Creation params.
     * @param {string} params.pid PID for the new object.
     * @param {*} params.object Object payload.
     * @param {string} params.sysMetaXml System metadata XML.
     * @param {string} [params.fileName] Optional filename.
     * @param {object} [options] Transfer options.
     * @returns {Promise<DataONEHttpResponse>} Upload response.
     * @throws {Error} When required params are missing or the request fails
     */
    async create(params = {}, options = {}) {
      const { pid, object, sysMetaXml, fileName } = params;
      const normalizedPid = this.constructor.normalizePid(pid);
      const normalizedSysMetaXml = ValueUtilities.requireNonEmptyString(
        sysMetaXml,
        "ObjectService: sysMetaXml is required",
      );
      if (object === undefined || object === null) {
        throw new Error("ObjectService: object is required");
      }

      return this.sendTransferRequest(
        "create",
        "",
        "POST",
        this.constructor.buildTransferFormData({
          pid: normalizedPid,
          object,
          sysMetaXml: normalizedSysMetaXml,
          fileName,
        }),
        "ObjectService.create",
        options,
      );
    }

    /**
     * Update an existing DataONE object.
     * @param {object} params Update params.
     * @param {string} params.pid Existing (old) PID.
     * @param {string} params.newPid Replacement PID.
     * @param {*} params.object Updated object payload.
     * @param {string} params.sysMetaXml Updated system metadata XML.
     * @param {string} [params.fileName] Optional filename.
     * @param {object} [options] Transfer options.
     * @returns {Promise<DataONEHttpResponse>} Update response.
     * @throws {Error} When required params are missing or the request fails
     */
    async update(params = {}, options = {}) {
      const { pid, newPid, object, sysMetaXml, fileName } = params;
      const normalizedPid = this.constructor.normalizePid(pid);
      const normalizedNewPid = this.constructor.normalizePid(newPid, "newPid");
      const normalizedSysMetaXml = ValueUtilities.requireNonEmptyString(
        sysMetaXml,
        "ObjectService: sysMetaXml is required",
      );
      if (object === undefined || object === null) {
        throw new Error("ObjectService: object is required");
      }

      return this.sendTransferRequest(
        "update",
        this.constructor.encodePidPath(normalizedPid),
        "PUT",
        this.constructor.buildTransferFormData({
          pid: normalizedPid,
          newPid: normalizedNewPid,
          object,
          sysMetaXml: normalizedSysMetaXml,
          fileName,
        }),
        "ObjectService.update",
        options,
      );
    }
  }

  /** @type {DataONEService#DataONEServiceConfig} */
  ObjectService.config = {
    endpoint: "object",
    persistPrivate: false,
    defaultAuth: true,
  };

  return ObjectService;
});