api-tdx.js

import base64 from "base-64";
import debug from "debug";
import {shortHash} from "@nqminds/nqm-core-utils";
import {
  buildAuthenticateRequest,
  buildCommandRequest,
  buildDatabotHostRequest,
  buildDatabotInstanceRequest,
  buildFileUploadRequest,
  buildQueryRequest,
  checkResponse,
  fetchWithDeadline as fetch,
  setDefaults,
  waitForAccount,
  waitForIndex,
} from "./helpers";

// Default 'debug' module output to STDOUT rather than STDERR.
debug.log = console.log.bind(console); // eslint-disable-line no-console

const log = debug("nqm-api-tdx");
const errLog = debug("nqm-api-tdx:error");

/**
 * @typedef  {object} CommandResult
 * @property  {string} commandId - The auto-generated unique id of the command.
 * @property  {object|string} response - The response of the command. If a command is sent asynchronously, this will
 * simply be the string `"ack"`. In synchronous mode, this will usually be an object consisting of the primary key
 * of the data that was affected by the command.
 * @property  {object} result - Contains success flag and detailed error information when available.
 * @property  {array} result.errors - Will contain error information when appropriate.
 * @property  {array} result.ok - Contains details of each successfully commited document.
 */

/**
 * @typedef  {object} DatasetData
 * @property  {object} metaData - The dataset metadata (see `nqmMeta` option in `getDatasetData`).
 * @property  {string} metaDataUrl - The URL to the dataset metadata (see `nqmMeta` option in `getDatasetData`.
 * @property  {object[]} data - The dataset documents.
 */

/**
 * @typedef  {object} Resource
 * @property  {string} description
 * @property  {string} id
 * @property  {string} name
 * @property  {string[]} parents
 * @property  {object} schemaDefinition
 * @property  {string[]} tags
 */

/**
 * @typedef  {object} ResourceAccess
 * @property  {string} aid - account id that is the subject of this access
 * @property  {string} by - comma-delimited list of attribution for this access
 * @property  {string} rid - resource id to which this access refers
 * @property  {string} grp - indicates the share mode (user groups only)
 * @property  {string} own - account that owns the resource
 * @property  {string[]} par - the parent(s) of the resource
 * @property  {string} typ - the base type of the resource
 * @property  {string[]} r - array of resource ids that are the source of read access (e.g. parent)
 * @property  {string[]} w - array of resource ids that are the source of write access
 */

/**
 * @typedef  {object} Zone
 * @property  {string} accountType
 * @property  {string} displayName
 * @property  {string} username
 */

/**
 * @typedef  {object} GetDataOptions A mongodb options object.
 * Can be used to limit, skip, sort etc. Note a default
 * `limit` of 1000 is applied if none is given here.
 * @property  {boolean} [nqmMeta] - When set, the resource metadata will be returned along with the dataset
 * data. Can be used to avoid a second call to `getResource`. Otherwise a URL to the metadata is provided.
 * @property  {number} [limit] - Limit, the number of entries to get.
 * @property  {number} [skip] - Skip, the number of entries to skip.
 * Prefer to use filters if possible, instead of large skips, as they are slow
 * on the TDX.
 * @property {object} [sort] - MongoDB Sort object.
 * See <https://docs.mongodb.com/manual/reference/method/cursor.sort/> for
 * more information.
 */

class TDXApi {
  /**
   * Create a TDXApi instance
   * @param  {object} config - the TDX configuration for the remote TDX
   * @param  {string} [config.tdxServer] - the URL of the TDX auth server, e.g. https://tdx.nqminds.com. Usually this
   * is the only host parameter needed, as long as the target TDX conforms to the standard service naming conventions
   * e.g. https://[service].[tdx-domain].com. In this case the individual service hosts can be derived from the tdxHost
   * name. Optionally, you can specify each individual service host (see below). Note you only need to provide the host
   * for services you intend to use. For example, if you only need query services, just provide the query host.
   * @param  {string} [config.commandServer] - the URL of the TDX command service, e.g. https://cmd.nqminds.com
   * @param  {string} [config.queryServer] - the URL of the TDX query service, e.g. https://q.nqminds.com
   * @param  {string} [config.databotServer] - the URL of the TDX databot service, e.g. https://databot.nqminds.com
   * @param  {string} [config.accessToken] - an access token that will be used to authorise commands and queries.
   * Alternatively you can use the authenticate method to acquire a token.
   * @param  {number} [config.accessTokenTTL] - the TTL in seconds of the access token created when authenticating.
   * @param  {boolean} [config.doNotThrow] - set to prevent throwing response errors. They will be returned in the
   * {@link CommandResult} object. This was set by default prior to 0.5.x
   * @example <caption>standard usage</caption>
   * import TDXApi from "nqm-api-tdx";
   * const api = new TDXApi({tdxServer: "https://tdx.acme.com"});
   */
  constructor(config) {
    this.config = Object.assign({}, config);
    this.accessToken = config.accessToken || config.authToken || "";
    setDefaults(this.config);
  }

  /**
   * Authenticates with the TDX, acquiring an authorisation token.
   * @param  {string} id - the account id, or a pre-formed credentials string, e.g. "DKJG8dfg:letmein"
   * @param  {string} secret - the account secret
   * @param  {number} [ttl=3600] - the Time-To-Live of the token in seconds, default is 1 hour. Will default to
   * config.accessTokenTTL if not given here.
   * @return  {string} The access token.
   * @exception Will throw if credentials are invalid or there is a network error contacting the TDX.
   * @example <caption>authenticate using a share key and secret</caption>
   * tdxApi.authenticate("DKJG8dfg", "letmein");
   * @example <caption>authenticate using custom ttl of 2 hours</caption>
   * tdxApi.authenticate("DKJG8dfg", "letmein", 7200);
   */
  authenticate(id, secret, ttl, ip) {
    let credentials;

    if (typeof secret !== "string") {
      // Assume the first argument is a pre-formed credentials string
      credentials = id;
      ip = ttl;
      ttl = secret;
    } else {
      // uri-encode the username and concatenate with secret.
      credentials = `${encodeURIComponent(id)}:${secret}`;
    }

    // Authorization headers must be base-64 encoded.
    credentials = base64.encode(credentials);

    const request = buildAuthenticateRequest.call(this, credentials, ip, ttl);
    return fetch
      .call(this, request)
      .then(checkResponse.bind(this, "authenticate"))
      .then((result) => {
        log(result);
        this.accessToken = result.access_token;
        return this.accessToken;
      })
      .catch((err) => {
        errLog(`authenticate error: ${err.message}`);
        return Promise.reject(err);
      });
  }

  /*
   *
   *  ACCOUNT COMMANDS
   *
   */

  /**
   * Adds an account to the TDX. An account can be an e-mail based user account, a share key (token) account,
   * a databot host, an application, or an account-set (user group).
   * @param  {object} options - new account options
   * @param  {string} options.accountType - the type of account, one of ["user", "token"]
   * @param  {boolean} [options.approved] - account is pre-approved (reserved for system use only)
   * @param  {string} [options.authService] - the authentication type, one of ["local", "oauth:google",
   * "oauth:github"]. Required for user-based accounts. Ignored for non-user accounts.
   * @param  {string} [options.displayName] - the human-friendly display name of the account, e.g. "Toby's share key"
   * @param  {number} [options.expires] - a timestamp at which the account expires and will no longer be granted a
   * token
   * @param  {string} [options.key] - the account secret. Required for all but oauth-based account types.
   * @param  {string} [options.owner] - the owner of the account.
   * @param  {boolean} [options.scratchAccess] - indicates this account can create resources in the owners scratch
   * folder. Ignored for all accounts except share key (token) accounts. Is useful for databots that need to create
   * intermediate or temporary resources without specifying a parent resource - if no parent resource is given
   * when a resource is created and scratch access is enabled, the resource will be created in the owner's scratch
   * folder.
   * @param  {object} [options.settings] - free-form JSON object for user data.
   * @param  {string} [options.username] - the username of the new account. Required for user-based accounts, and
   * should be the account e-mail address. Can be omitted for non-user accounts, and will be auto-generated.
   * @param  {boolean} [options.verified] - account is pre-verified (reserved for system use only)
   * @param  {string[]} [options.whitelist] - a list of IP addresses. Tokens will only be granted if the requesting
   * IP address is in this list
   * @param  {boolean} [wait=false] - flag indicating this method will wait for the account to be fully created before
   * returning.
   * @return  {CommandResult}
   */
  addAccount(options, wait) {
    const request = buildCommandRequest.call(this, "account/create", options);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.addAccount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "addAccount"))
      .then((result) => {
        if (wait) {
          return waitForAccount.call(this, options.username, options.verified, options.approved).then(() => {
            return result;
          });
        } else {
          return result;
        }
      });
  }

  /**
   * Adds the application/user connection resource. The authenticated token must belong to the application.
   * @param {string} accountId - the account id
   * @param {string} applicationId - the application id
   * @param {boolean} [wait=true] - whether or not to wait for the projection to catch up.
   */
  addAccountApplicationConnection(accountId, applicationId, wait = true) {
    const request = buildCommandRequest.call(this, "applicationConnection/create", {accountId});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.addAccountApplicationConnection: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "addAccountApplicationConnection"))
      .then((result) => {
        if (wait) {
          const applicationUserId = shortHash(`${applicationId}-${accountId}`);
          return waitForIndex.call(this, applicationUserId).then(() => {
            return result;
          });
        } else {
          return result;
        }
      });
  }

  /**
   * Set account approved status. Reserved for system use.
   * @param  {string} username - the full TDX identity of the account.
   * @param  {boolean} approved - account approved status
   */
  approveAccount(username, approved) {
    const request = buildCommandRequest.call(this, "account/approve", {username, approved});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.approveAccount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "approveAccount"));
  }

  /**
   * Delete an account
   * @param  {string} username - the full TDX identity of the account to delete.
   */
  deleteAccount(username) {
    const request = buildCommandRequest.call(this, "account/delete", {username});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.deleteAccount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "deleteAccount"));
  }

  /**
   * Change account secret.
   * @param  {string} username - the full TDX identity of the account.
   * @param  {string} key - the new secret
   */
  resetAccount(username, key) {
    const request = buildCommandRequest.call(this, "account/reset", {username, key});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.resetAccount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "resetAccount"));
  }

  /**
   * Updates account details. All update properties are optional. See createAccount for full details of
   * each option.
   * @param  {string} username - the full TDX identity of the account to update.
   * @param  {object} options - the update options
   * @param  {string} [options.displayName]
   * @param  {string} [options.key]
   * @param  {boolean} [options.scratchAccess]
   * @param  {object} [options.settings]
   * @param  {string[]} [options.whitelist]
   */
  updateAccount(username, options) {
    const request = buildCommandRequest.call(this, "account/update", Object.assign({username}, options));
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.updateAccount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "addAccount"));
  }

  /**
   * Set account verified status. Reserved for system use.
   * @param  {string} username - the full TDX identity of the account.
   * @param  {boolean} approved - account verified status
   */
  verifyAccount(username, verified) {
    const request = buildCommandRequest.call(this, "account/verify", {username, verified});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.verifyAccount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "verifyAccount"));
  }

  /*
   *
   *  RESOURCE COMMANDS
   *
   */

  /**
   * Adds a data exchange to the list of trusted exchanges known to the current TDX.
   * @param  {object} options
   * @param  {string} options.owner - the account on this TDX to which the trust relates,
   * e.g. `bob@mail.com/tdx.acme.com`
   * @param  {string} options.targetServer - the TDX to be trusted, e.g. `tdx.nqminds.com`
   * @param  {string} options.targetOwner - the account on the target TDX that is trusted,
   * e.g. `alice@mail.com/tdx.nqminds.com`.
   */
  addTrustedExchange(options) {
    const request = buildCommandRequest.call(this, "trustedConnection/create", options);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.addTrustedExchange: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "addTrustedExchange"));
  }

  /**
   * Adds a resource to the TDX.
   * @param  {object} options - details of the resource to be added.
   * @param  {string} [options.basedOnSchema=dataset] - the id of the schema on which this resource will be based.
   * @param  {object} [options.derived] - definition of derived filter, implying this resource is a view on an existing
   * dataset.
   * @param  {object} [options.derived.filter] - the (read) filter to apply, in mongodb query format,
   * e.g. `{"temperature": {"$gt": 15}}` will mean that only data with a temperature value greater than 15 will be
   * available in this view. The filter can be any arbitrarily complex mongodb query. Use the placeholder
   * `"@@_identity_@@"` to indicate that the identity of the currently authenticated user should be substituted.
   * For example, if the user `bob@acme.com/tdx.acme.com` is currently authenticated, a filter of `{"username":
   *  "@@_identity_@@"}` will resolve at runtime to `{"username": "bob@acme.com/tdx.acme.com"}`.
   * @param  {object} [options.derived.projection] - the (read) projection to apply, in mongodb projection format,
   * e.g. `{"timestamp": 1, "temperature": 1}` implies only the 'timestamp' and 'temperature' properties will be
   * returned.
   * @param  {string} [options.derived.source] - the id of the source dataset on which to apply the filters and
   * projections.
   * @param  {object} [options.derived.writeFilter] - the write filter to apply, in mongodb query format. This
   * controls what data can be written to the underlying source dataset. For example, a write filter of
   * `{"temperature": {"$lt": 40}}` means that attempts to write a temperature value greater than or equal to `40`
   * will fail. The filter can be any arbitrarily complex mongodb query.
   * @param  {object} [options.derived.writeProjection] - the write projection to apply, in mongodb projection format.
   * This controls what properties can be written to the underlying dataset. For example, a write projection of
   * `{"temperature": 1}` means that only the temperature field can be written, and attempts to write data to other
   * properties will fail. To allow a view to create new data in the underlying dataset, the primary key fields
   * must be included in the write projection.
   * @param  {string} [options.description] - a description for the resource.
   * @param  {string} [options.id] - the requested ID of the new resource. Must be unique. Will be auto-generated if
   * omitted (recommended).
   * @param  {string} options.name - the name of the resource. Must be unique in the parent folder.
   * @param  {object} [options.meta] - a free-form object for storing metadata associated with this resource.
   * @param  {string} [options.parentId] - the id of the parent resource. If omitted, will default to the appropriate
   * root folder based on the type of resource being created.
   * @param  {string} [options.provenance] - a description of the provenance of the resource. Markdown format is
   * supported.
   * @param  {string} [options.queryProxy] - a url or IP address that will handle all queries to this resource
   * @param  {object} [options.schema] - optional schema definition.
   * @param  {string} [options.shareMode] - the share mode assigned to the new resource. One of [`"pw"`, `"pr"`,
   * `"tr"`], corresponding to "public read/write", "public read/trusted write", "trusted only".
   * @param  {string[]} [options.tags] - a list of tags to associate with the resource.
   * @param  {string} [options.textContent] - the text content for the resource. Only applicable to text content based
   * resources.
   * @param  {boolean|string} [wait=false] - indicates if the call should wait for the index to be built before it
   * returns. You can pass a string here to indicate the status you want to wait for, default is 'built'.
   * @example <caption>usage</caption>
   * // Creates a dataset resource in the authenticated users' scratch folder. The dataset stores key/value pairs
   * // where the `key` property is the primary key and the `value` property can take any JSON value.
   * tdxApi.addResource({
   *   name: "resource #1",
   *   schema: {
   *     dataSchema: {
   *       key: "string",
   *       value: {}
   *     },
   *     uniqueIndex: {key: 1}
   *   }
   * })
   */
  addResource(options, wait) {
    const request = buildCommandRequest.call(this, "resource/create", options);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.addResource: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "addResource"))
      .then((result) => {
        if (wait) {
          return waitForIndex.call(this, result.response.id, wait === true ? "" : wait).then(() => {
            return result;
          });
        } else {
          return result;
        }
      });
  }

  /**
   * Adds read and/or write permission for an account to access a resource. Permission is required
   * equivalent to that which is being added, e.g. adding write permission requires existing
   * write access. Note access as a result of group membership is not included in determining
   * permissions, in this case add the account to the group instead.
   * @param  {string} resourceId - The resource id
   * @param  {string} accountId - The account id to assign permission to
   * @param  {string} sourceId - The id of the resource acting as the source of the access. This
   * is usually the same as the target `resourceId`, but can also be a parent resource. For example,
   * if write access is granted with the sourceId set to be a parent, then if the permission is
   * revoked from the parent resource it will also be revoked from this resource.
   * @param  {string[]} access - The access, one or more of [`"r"`, `"w"`]. Can be an array or an individual
   * string.
   * @example <caption>add access to an account</caption>
   * tdxApi.addResourceAccess(myResourceId, "bob@acme.com/tdx.acme.com", myResourceId, ["r"]);
   */
  addResourceAccess(resourceId, accountId, sourceId, access) {
    const request = buildCommandRequest.call(this, "resourceAccess/add", {
      rid: resourceId,
      aid: accountId,
      src: sourceId,
      acc: [].concat(access),
    });
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.addResourceAccess: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "addResourceAccess"));
  }

  /**
   * Permanently deletes a resource.
   * @param  {string} resourceId - the id of the resource to delete. Requires write permission
   * to the resource.
   */
  deleteResource(resourceId) {
    const request = buildCommandRequest.call(this, "resource/delete", {id: resourceId});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.deleteResource: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "deleteResource"));
  }

  /**
   * Permanently deletes a list of resources.
   * Will fail **all** deletes if any of the permission checks fail.
   * @param  {Resource[]} resourceList - The list of resources to delete. Note only the `id` property of each
   * resource is required.
   * @return  {CommandResult}
   */
  deleteManyResources(resourceIdList) {
    const request = buildCommandRequest.call(this, "resource/deleteMany", {payload: resourceIdList});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.deleteManyResources: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "deleteManyResources"));
  }

  /**
   * Upload a file to a resource.
   * @param  {string} resourceId - The id of the destination resource.
   * @param  {object} file - The file to upload, obtained from an `<input type="file">` element.
   * @param  {boolean} [stream=false] - Flag indicating whether the call should return a stream allowing
   * callees to monitor progress.
   * @param  {boolean} [compressed=false] - Flag indicating the file should be decompressed after upload. ZIP format
   * only.
   * @param  {boolean} [base64Encoded=false] = Flag indicating the file should be decoded from base64 after upload.
   */
  fileUpload(resourceId, file, stream, compressed = false, base64Encoded = false) {
    const request = buildFileUploadRequest.call(this, resourceId, compressed, base64Encoded, file);
    const response = fetch.call(this, request).catch((err) => {
      errLog("TDXApi.fileUpload: %s", err.message);
      return Promise.reject(new Error(`${err.message} - [network error]`));
    });

    if (stream) {
      return response;
    } else {
      return response.then(checkResponse.bind(this, "fileUpload"));
    }
  }

  /**
   * Move resource from one folder to another. Requires write permission on the resource, the
   * source parent and the target parent resources.
   * @param  {string} id - the id of the resource to move.
   * @param  {string} fromParentId - the current parent resource to move from.
   * @param  {string} toParentId - the target folder resource to move to.
   */
  moveResource(id, fromParentId, toParentId) {
    const request = buildCommandRequest.call(this, "resource/move", {id, fromParentId, toParentId});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.moveResource: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "moveResource"));
  }

  /**
   * Resets the resource index. This involves deleting existing indexes and rebuilding them. May take
   * a while depending on the size of any associated dataset and the number and complexity of indexes.
   * @param  {string} resourceId - the id of the resource, requires write permission.
   */
  rebuildResourceIndex(resourceId) {
    const request = buildCommandRequest.call(this, "resource/index/rebuild", {id: resourceId});
    let result;
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.rebuildResourceIndex: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "rebuildIndex"))
      .then((res) => {
        result = res;
        return waitForIndex.call(this, result.response.id, "built");
      })
      .then(() => {
        return result;
      });
  }

  /**
   * Removes access for an account to a resource. Permission is required
   * equivalent to that which is being added, e.g. adding write permission requires existing
   * write access.
   * @param  {string} resourceId - The resource id.
   * @param  {string} accountId - The account id to remove access from.
   * @param  {string} addedBy - The full `by` path of the permission to be removed.
   * @param  {string} sourceId - The source of the access, usually the resource itself.
   * @param  {string[]} access - The access, one or more of [`"r"`, `"w"`].
   * @example <caption>usage</caption>
   * // Removes read access for account "john.trulove@nqminds.com/tdx.nqm-1.com" added by
   * // Toby via "ezh.ubiapps@gmail.com/tdx.nqm-1.com,toby.ealden@gmail.com/tdx.nqm-1.com".
   * tdxApi.removeResourceAccess(
   *  "rJg9ldNEwD",
   *  "john.trulove@nqminds.com/tdx.nqm-1.com",
   *  "ezh.ubiapps@gmail.com/tdx.nqm-1.com,toby.ealden@gmail.com/tdx.nqm-1.com",
   *  "rJg9ldNEwD",
   *  ["r"]
   * );
   */
  removeResourceAccess(resourceId, accountId, addedBy, sourceId, access) {
    const request = buildCommandRequest.call(this, "resourceAccess/delete", {
      rid: resourceId,
      aid: accountId,
      by: addedBy,
      src: sourceId,
      acc: access,
    });
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.removeResourceAccess: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "removeResourceAccess"));
  }

  /**
   * Set the resource import flag.
   * @param  {string} resourceId - The id of the dataset-based resource.
   * @param  {boolean} importing - Indicates the state of the import flag.
   * @return  {CommandResult}
   */
  setResourceImporting(resourceId, importing) {
    const request = buildCommandRequest.call(this, "resource/importing", {id: resourceId, importing});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.setResourceImporting: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "setResourceImporting"));
  }

  /**
   * Set the resource schema.
   * @param  {string} resourceId - The id of the dataset-based resource.
   * @param  {object} schema - The new schema definition. TODO - document
   * @return  {CommandResult}
   */
  setResourceSchema(resourceId, schema) {
    const request = buildCommandRequest.call(this, "resource/schema/set", {id: resourceId, schema});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.setResourceSchema: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "setResourceSchema"));
  }

  /**
   * Set the share mode for a resource.
   * @param  {string} resourceId - The resource id.
   * @param  {string} shareMode - The share mode to set, one or [`"pw"`, `"pr"`, `"tr"`] corresponding to
   * 'public read/write', 'public read, trusted write', 'trusted only'.
   */
  setResourceShareMode(resourceId, shareMode) {
    const request = buildCommandRequest.call(this, "resource/setShareMode", {id: resourceId, shareMode});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.setResourceShareMode: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "setResourceShareMode"));
  }

  /**
   * Sets the permissive share mode of the resource. Permissive share allows anybody with acces to the resource
   * to share it with others. If a resource is not in permissive share mode, only the resource owner
   * can share it with others.
   * @param  {string} resourceId - The resource id.
   * @param  {boolean} allowPermissive - The required permissive share mode.
   */
  setResourcePermissiveShare(resourceId, allowPermissive) {
    const request = buildCommandRequest.call(this, "resource/setPermissiveShare", {
      id: resourceId,
      permissiveShare: allowPermissive ? "r" : "",
    });
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.setResourcePermissiveShare: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "setResourcePermissiveShare"));
  }

  /**
   * Sets the dataset store of the resource. Reserved for system use.
   * @param  {string} resourceId - The resource id.
   * @param  {string} store - The name of the store.
   * @param  {number} [storeSize] - The size in bytes of the store.
   */
  setResourceStore(resourceId, store, storeSize) {
    const request = buildCommandRequest.call(this, "resource/store/set", {
      id: resourceId,
      store,
      storeSize,
    });
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.setResourceStore: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "setResourceStore"));
  }

  /**
   * Set the text for a text-content based resource.
   * @param  {string} resourceId - The resource id.
   * @param  {string} textContent - The text content to set.
   * @example <caption>usage</caption>
   * // Sets the text content for a text-html resource.
   * tdxApi.setResourceTextContent(
   *   "HyeqJgVdJ7",
   *   "<html><body><p>Hello World</p></body></html>"
   * );
   */
  setResourceTextContent(resourceId, textContent) {
    const request = buildCommandRequest.call(this, "resource/textContent/set", {id: resourceId, textContent});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.setResourceTextContent: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "setResourceTextContent"));
  }

  /**
   * Suspends the resource index. This involves deleting any existing indexes. Requires write permission. When
   * a resource index is in `suspended` status, it is not possible to run any queries or updates against
   * the resource.
   * @param  {string} resourceId - the id of the resource. Requires write permission.
   */
  suspendResourceIndex(resourceId) {
    const request = buildCommandRequest.call(this, "resource/index/suspend", {id: resourceId});
    let result;
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.suspendResourceIndex: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "suspendIndex"))
      .then((res) => {
        result = res;
        return waitForIndex.call(this, result.response.id, "suspended");
      })
      .then(() => {
        return result;
      });
  }

  /**
   * Removes all data from the resource. Applicable to dataset-based resources only. This can not be
   * undone.
   * @param  {string} resourceId - The resource id to truncate.
   */
  truncateResource(resourceId) {
    const request = buildCommandRequest.call(this, "resource/truncate", {id: resourceId});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.truncateResource: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "truncateResource"));
  }

  /**
   * Modify one or more of the meta data associated with the resource.
   * @param  {string} resourceId - id of the resource to update
   * @param  {object} update - object containing the properties to update. Can be one or more of those
   * listed below. See the {@link TDXApi#addResource} method for semantics and syntax of each property.
   * @param  {string} [update.derived]
   * @param  {string} [update.description]
   * @param  {object} [update.meta]
   * @param  {string} [update.name]
   * @param  {boolean} [update.overwrite] - set this flag to overwrite existing data rather than merging (default). This
   * currently only applies to the `meta` property.
   * @param  {string} [update.provenance]
   * @param  {string} [update.queryProxy]
   * @param  {array} [update.tags]
   * @param  {string} [update.textContent] see also {@link TDXApi#setResourceTextContent}
   */
  updateResource(resourceId, update) {
    const request = buildCommandRequest.call(this, "resource/update", Object.assign({id: resourceId}, update));
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.updateResource: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "updateResource"));
  }

  /*
   *
   *  RESOURCE DATA COMMANDS
   *
   */

  /**
   * Add data to a dataset resource.
   * @param  {string} datasetId - The id of the dataset-based resource to add data to.
   * @param  {object|array} data - The data to add. Must conform to the schema defined by the resource metadata.
   * @param  {boolean} [doNotThrow=false] - set to override default error handling. See {@link TDXApi}.
   * Supports creating an individual document or many documents.
   * @example <caption>create an individual document</caption>
   * // Assumes the dataset primary key is 'lsoa'
   * tdxApi.addData(myDatasetId, {lsoa: "E0000001", count: 398});
   * @example <caption>create multiple documents</caption>
   * tdxApi.addData(myDatasetId, [
   *  {lsoa: "E0000001", count: 398},
   *  {lsoa: "E0000002", count: 1775},
   *  {lsoa: "E0000005", count: 4533},
   * ]);
   */
  addData(datasetId, data, doNotThrow) {
    const postData = {
      datasetId,
      payload: [].concat(data),
    };
    const request = buildCommandRequest.call(this, "dataset/data/createMany", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.addData: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "addData", doNotThrow));
  }

  /**
   * Deletes data from a dataset-based resource.
   * @param  {string} datasetId - The id of the dataset-based resource to delete data from.
   * @param  {object|array} data - The primary key data to delete.
   * @param  {boolean} [doNotThrow=false] - set to override default error handling. See {@link TDXApi}.
   */
  deleteData(datasetId, data, doNotThrow) {
    const postData = {
      datasetId,
      payload: [].concat(data),
    };
    const request = buildCommandRequest.call(this, "dataset/data/deleteMany", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.deleteData: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "deleteData", doNotThrow));
  }

  /**
   * Deletes data from a dataset-based resource using a query to specify the documents to be deleted.
   * @param  {string} datasetId - The id of the dataset-based resource to delete data from.
   * @param  {object} query - The query that specifies the data to delete. All documents matching the
   * query will be deleted.
   * @param  {boolean} [doNotThrow=false] - set to override default error handling. See {@link TDXApi}.
   * @example
   * // Delete all documents with English lsoa.
   * tdxApi.deleteDataByQuery(myDatasetId, {lsoa: {$regex: "E*"}});
   */
  deleteDataByQuery(datasetId, query, doNotThrow) {
    const postData = {
      datasetId,
      query: JSON.stringify(query),
    };
    const request = buildCommandRequest.call(this, "dataset/data/deleteQuery", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.deleteDataByQuery: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "deleteDataByQuery", doNotThrow));
  }

  /**
   * Patches data in a dataset resource. Uses the [JSON patch](https://tools.ietf.org/html/rfc6902) format,
   * which involves defining the primary key data followed by a flexible update specification.
   * @param  {string} datasetId - The id of the dataset-based resource to update.
   * @param  {object} data - The patch definition.
   * @param  {object|array} data.__update - An array of JSON patch specifications.
   * @param  {boolean} [doNotThrow=false] - set to override default error handling. See {@link TDXApi}.
   * @example <caption>patch a single value in a single document</caption>
   * tdxApi.patchData(myDatasetId, {lsoa: "E000001", __update: [{path: "/count", op: "replace", value: 948}]});
   * @example <caption>patch a more than one value in a single document</caption>
   * tdxApi.patchData(myDatasetId, {lsoa: "E000001", __update: [
   *   {path: "/count", op: "replace", value: 948}
   *   {path: "/modified", op: "add", value: Date.now()}
   * ]});
   */
  patchData(datasetId, data, doNotThrow) {
    const postData = {
      datasetId,
      payload: [].concat(data),
    };
    const request = buildCommandRequest.call(this, "dataset/data/patchMany", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.patchData: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "patchData", doNotThrow));
  }

  /**
   * Updates data in a dataset resource.
   * @param  {string} datasetId - The id of the dataset-based resource to update.
   * @param  {object|array} data - The data to update. Must conform to the schema defined by the resource metadata.
   * Supports updating individual or multiple documents.
   * @param  {boolean} [upsert=false] - Indicates the data should be created if no document is found matching the
   * primary key.
   * @param  {boolean} [doNotThrow=false] - set to override default error handling. See {@link TDXApi}.
   * @param  {object} [opts] - reserved for system use.
   * @return {CommandResult} - Use the result property to check for errors.
   * @example <caption>update an existing document</caption>
   * tdxApi.updateData(myDatasetId, {lsoa: "E000001", count: 488});
   * @example <caption>upsert a document</caption>
   * // Will create a document if no data exists matching key 'lsoa': "E000004"
   * tdxApi.updateData(myDatasetId, {lsoa: "E000004", count: 288}, true);
   */
  updateData(datasetId, data, upsert, doNotThrow, opts) {
    const postData = {
      datasetId,
      payload: [].concat(data),
      __upsert: !!upsert,
      __opts: opts,
    };
    const request = buildCommandRequest.call(this, "dataset/data/updateMany", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.updateData: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "updateData", doNotThrow));
  }

  /**
   * Updates data in a dataset-based resource using a query to specify the documents to be updated.
   * @param  {string} datasetId - The id of the dataset-based resource to update data in.
   * @param  {object} query - The query that specifies the data to update. All documents matching the
   * @param  {boolean} [doNotThrow=false] - set to override default error handling. See {@link TDXApi}.
   * query will be updated.
   * @example
   * // Update all documents with English lsoa, setting `count` to 1000.
   * tdxApi.updateDataByQuery(myDatasetId, {lsoa: {$regex: "E*"}}, {count: 1000});
   */
  updateDataByQuery(datasetId, query, update, doNotThrow) {
    const postData = {
      datasetId,
      query: JSON.stringify(query),
      update,
    };
    const request = buildCommandRequest.call(this, "dataset/data/updateQuery", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.updateDataByQuery: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "updateDataByQuery", doNotThrow));
  }

  /*
   *
   *  DATABOT COMMANDS
   *
   */

  /**
   * Deletes one or more hosts, depending on the given parameters. E.g. if just a `hostId` is given, all hosts
   * will be deleted with that id. If an ip address is also given, all hosts with the id on that ip address will
   * be deleted and so on. Note that hosts can only be deleted if they are in the `offline` status.
   * @param  {object} payload - The definition of the host(s) to delete. Can be an array of objects or a single object
   * @param  {string} payload.hostId - The id of the hosts to be deleted.
   * @param  {string} [payload.hostIp] - The optional ip of the hosts to be deleted.
   * @param  {number} [payload.hostPort] - The optional port number of the host to be deleted.
   */
  deleteDatabotHost(payload) {
    const postData = {
      payload,
    };
    const request = buildCommandRequest.call(this, "databot/host/delete", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.deleteDatabotHost: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "deleteDatabotHost"));
  }

  /**
   * Deletes a databot instance and all output/debug data associated with it.
   * @param  {string[]} instanceId - The id(s) of the instances to delete. Can be an array of instance ids or an
   * individual string id
   */
  deleteDatabotInstance(instanceId) {
    const postData = {
      instanceId: [].concat(instanceId),
    };
    const request = buildCommandRequest.call(this, "databot/deleteInstance", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.deleteDatabotInstance: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "deleteDatabotInstance"));
  }

  /**
   * Gets databot instance data for the given instance id.
   * @param  {string} instanceId - The id of the instance to retrieve.
   */
  getDatabotInstance(instanceId) {
    const request = buildDatabotInstanceRequest.call(this, instanceId);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getDatabotInstance: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getDatabotInstance"));
  }

  /**
   * Get databot instance output.
   * @param  {string} instanceId - The instance id to retrieve output for.
   * @param  {string} [processId] - Optional process id. If omitted, output for all instance processes will be returned.
   */
  getDatabotInstanceOutput(instanceId, processId) {
    const request = buildDatabotInstanceRequest.call(this, `output/${instanceId}/${processId || ""}`);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getDatabotInstanceOutput: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getDatabotInstanceOutput"));
  }

  /**
   * Get databot instance status.
   * @param  {string} instanceId - The id of the databot instance for which status is retrieved.
   */
  getDatabotInstanceStatus(instanceId) {
    const request = buildDatabotInstanceRequest.call(this, `status/${instanceId}`);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getDatabotInstanceStatus: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getDatabotInstanceStatus"));
  }

  /**
   * Registers a databot host with the TDX. Once registered, a host is eligible to receive commands from the TDX.
   * @param  {object} payload - The databot host identifier payload.
   * @param  {number} payload.port - the port number the host is listening on.
   * @param  {string} payload.version - the databot host software version.
   * @param  {string} payload.hostStatus - the current status of the host, "idle" or "busy".
   * @param  {string} [payload.ip] - optional ip address of the host. Usually the TDX can deduce this from the incoming
   * request.
   * @example <caption>register a databot host</caption>
   * tdxApi.registerDatabotHost({version: "0.3.11", port: 2312, hostStatus: "idle"});
   */
  registerDatabotHost(payload) {
    const request = buildDatabotHostRequest.call(this, "register", payload);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.registerDatabotHost: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "registerDatabotHost"));
  }

  /**
   * Sends a command to a databot host. Reserved for system use.
   * @param  {string} command - The command to send. Must be one of ["stopHost", "updateHost", "runInstance",
   * "stopInstance", "clearInstance"]
   * @param  {string} hostId - The id of the host.
   * @param  {string} [hostIp] - The ip address of the host. If omitted, the command will be sent to all
   * host ip addresses.
   * @param  {number} [hostPort] - The port number of the host. If omitted, the command will be sent to
   * all host ports.
   * @param  {object} [payload] - The command payload.
   */
  sendDatabotHostCommand(command, hostId, hostIp, hostPort, payload) {
    const postData = {
      hostId,
      hostIp,
      hostPort,
      command,
      payload,
    };
    const request = buildCommandRequest.call(this, "databot/host/command", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.sendDatabotHostCommand: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "sendDatabotHostCommand"));
  }

  /**
   * Starts a databot instance.
   * @param  {string} databotId - The id of the databot definition to start.
   * @param  {object} payload - The instance input and parameters.
   * @param  {string} [payload.id] - The id to assign to the new instance. This will be auto-generated if omitted.
   * If there is an existing instance with this id the `overwriteExisting` flag must be set (see below).
   * @param  {number} [payload.authTokenTTL] - The time-to-live to use when creating the auth token, in seconds.
   * Will default to the TDX-configured default if not given (usually 1 hour).
   * @param  {number} [payload.chunks=1] - The number of processes to instantiate. Each will be given the same input
   * data, with only the chunk number varying.
   * @param  {boolean} [payload.debugMode=false] - Flag indicating this instance should be run in debug mode, meaning
   * all debug output will be captured and stored on the TDX. n.b. setting this will also restrict the hosts available
   * to run the instance to those that are willing to run in debug mode.
   * @param  {string} [payload.description] - The description for this instance.
   * @param  {object} [payload.inputs] - The input data. A free-form object that should conform to the
   * specification in the associated databot definition.
   * @param  {string} [payload.name] - The name to associate with this instance, e.g. "Male population
   * projection 2017"
   * @param  {boolean} [payload.overwriteExisting] - Set to allow existing instances to be overwritten (see the `id`
   * property above). Note it is not possible to overwrite a running instance.
   * @param  {number} [payload.priority] - The priority to assign this instance. Reserved for system use.
   * @param  {string} payload.shareKeyId - The share key to run the databot under.
   * @param  {string} [payload.shareKeySecret] - The secret of the share key. Ignored if the share key id refers to a
   * user-based account.
   */
  startDatabotInstance(databotId, payload) {
    const postData = {
      databotId,
      instanceData: payload,
    };
    const request = buildCommandRequest.call(this, "databot/startInstance", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.startDatabotInstance: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "startDatabotInstance"));
  }

  /**
   * Aborts a running databot instance.
   * @param  {string} instanceId - The id of the instance to abort.
   */
  abortDatabotInstance(instanceId) {
    const postData = {
      instanceId,
    };
    const request = buildCommandRequest.call(this, "databot/abortInstance", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.abortDatabotInstance: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "abortDatabotInstance"));
  }

  /**
   * Terminates or pauses a running databot instance.
   * @param  {string} instanceId - The id of the instance to terminate or pause.
   * @param  {string} mode - One of [`"stop"`, `"pause"`, `"resume"`]
   */
  stopDatabotInstance(instanceId, mode) {
    const postData = {
      instanceId,
      mode,
    };
    const request = buildCommandRequest.call(this, "databot/stopInstance", postData);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.stopDatabotInstance: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "stopDatabotInstance"));
  }

  /**
   * Updates a databot host status.
   *
   * n.b. the response to this request will contain any commands from the TDX that the host should action (
   * [see commands](https://github.com/nqminds/nqm-databots/tree/master/packages/nqm-databot-host#tdx-command-format)).
   * @param  {object} payload - The databot host status payload.
   * @param  {number} payload.port - The port number on which the host is listening.
   * @param  {string} payload.hostStatus - The current host status, either "idle" or "busy".
   * @param  {string} [payload.ip] - optional ip address of the host. Usually the TDX can deduce this from the incoming
   * request.
   * @example <caption>update databot host status</caption>
   * tdxApi.updateDatabotHostStatus({port: 2312, hostStatus: "idle"});
   */
  updateDatabotHostStatus(payload) {
    const request = buildDatabotHostRequest.call(this, "status", payload);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.updateDatabotHostStatus: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "updateDatabotHostStatus"));
  }

  /**
   * Stores databot instance output on the TDX.
   * @param  {object} output - The output payload for the databot instance.
   */
  writeDatabotHostInstanceOutput(output) {
    const request = buildDatabotHostRequest.call(this, "output", output);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.writeDatabotHostInstanceOutput: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "writeDatabotHostInstanceOutput"));
  }

  /*
   *
   *  ZONE CONNECTION COMMANDS
   *
   */

  /**
   * Adds a zone connection to a remote TDX. The details for the connection should be retrieved by a call to the
   * certificate endpoint for the TDX, e.g. https://tdx.nqminds.com/certficate.
   * @param  {object} options - The zone connection details
   * @param  {string} options.owner - The owner of the zone connection. Must be the same as the authenticated account.
   * @param  {string} options.tdxServer - The URL of the target TDX auth server, e.g. https://tdx.nqminds.com
   * @param  {string} [options.commandServer] - The URL of the target TDX command server, e.g. https://cmd.nqminds.com
   * @param  {string} [options.queryServer] - The URL of the target TDX query server, e.g. https://q.nqminds.com
   * @param  {string} [options.ddpServer] - The URL of the target TDX ddp server, e.g. https://ddp.nqminds.com
   * @param  {string} [options.databotServer] - The URL of the target TDX databot server,
   * e.g. https://databot.nqminds.com
   * @param  {string} [options.displayName] - The friendly name of the TDX.
   */
  addZoneConnection(options) {
    const request = buildCommandRequest.call(this, "zoneConnection/create", options);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.addZoneConnection: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "addZoneConnection"));
  }

  /**
   * Deletes a zone connection. The authenticated account must own the zone connection.
   * @param  {string} id - The id of the zone connection to delete.
   */
  deleteZoneConnection(id) {
    const request = buildCommandRequest.call(this, "zoneConnection/delete", {id});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.deleteZoneConnection: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "deleteZoneConnection"));
  }

  /**
   * AUDIT COMMANDS
   */

  rollbackCommand(id) {
    const request = buildCommandRequest.call(this, "rollback", {id});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.rollbackCommand: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "rollbackCommand"));
  }

  /*
   *
   *  QUERIES
   *
   */

  /**
   * Creates a client user token (e.g. bound to the browser IP) for an application-user token bound to the
   * given IP or the currently authenticated token IP. The currently authenticated token ***must*** be an application
   * token, whereby the application has been authorised by the user and the user has permission to access the
   * application. The returned token will be bound to the given IP or the IP of the currently authenticated token
   * (i.e the application server IP).
   *
   * @param  {string} username - The users' TDX id.
   * @param  {string} [ip] - The optional IP address to bind the user token to.
   * @param  {number} [ttl] - The ttl in seconds.
   * @return  {object} - The new application-user token, bound to the given IP.
   * @example <caption>create token bound to server ip with default TDX ttl</caption>
   * tdxApi.createTDXToken("bob@bob.com/acme.tdx.com");
   * @example <caption>create for specific IP</caption>
   * tdxApi.createTDXToken("bob@bob.com/acme.tdx.com", newClientIP);
   */
  createTDXToken(username, ip, ttl) {
    const request = buildQueryRequest.call(this, "token/create", {username, ip, ttl});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.createTDXToken: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "createTDXToken"));
  }

  /**
   * Exchanges a client user token (e.g. bound to the browser IP) for an application-user token bound to the
   * given IP or the currently authenticated token IP. The currently authenticated token ***must*** be an application
   * token, whereby the application has been authorised by the user and the user has permission to access the
   * application. The returned token will be bound to the given IP or the IP of the currently authenticated token
   * (i.e the application server IP).
   *
   * @param  {string} token - The users' TDX auth server token to validate.
   * @param  {string} [validateIP] - The optional IP address to validate the user token against.
   * @param  {string} [exchangeIP] - The optional IP address to bind the new token to.
   * @param  {number} [ttl] - The ttl in seconds.
   * @return  {object} - The new token application-user token, bound to the server IP.
   * @example <caption>validate against current IP</caption>
   * tdxApi.exchangeTDXToken(clientToken);
   * @example <caption>validate against different IP</caption>
   * tdxApi.exchangeTDXToken(clientToken, newClientIP);
   * @example <caption>validate against current IP, bind to a new IP</caption>
   * tdxApi.exchangeTDXToken(clientToken, null, serverIP);
   */
  exchangeTDXToken(token, validateIP, exchangeIP, ttl) {
    const request = buildQueryRequest.call(this, "token/exchange", {token, ip: validateIP, exchangeIP, ttl});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.exchangeTDXToken: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "exchangeTDXToken"));
  }

  /**
   * Streams the contents of a resource. For dataset-based resources this will stream the dataset contents in newline
   * delimited JSON (NDJSON). For raw file resources this will stream the raw file contents (zip, raw JSON etc).
   * @param  {string} resourceId - The id of the resource to be downloaded.
   * @return {object} - Response object, where the response body is a stream object.
   */
  downloadResource(resourceId) {
    const request = buildQueryRequest.call(this, `resources/${resourceId}/download`);
    return fetch.call(this, request).catch((err) => {
      errLog("TDXApi.downloadResource: %s", err.message);
      return Promise.reject(new Error(`${err.message} - [network error]`));
    });
  }

  /**
   * Gets the details for a given account id.
   * @param  {string} accountId - the id of the account to be retrieved.
   * @return  {Zone} zone
   */
  getAccount(accountId) {
    const request = buildQueryRequest.call(this, "accounts", {username: accountId});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getAccount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getAccount"))
      .then((accountList) => {
        return accountList && accountList.length ? accountList[0] : null;
      });
  }

  /**
   * Gets the details for all peer accounts.
   * @param  {object} filter - query filter.
   * @param  {string} filter.accountType - the account type to filter by, e.g. "user", "token", "host" etc.
   * @return  {Zone[]} zone
   * @example <caption>Get all databots owned by bob</caption>
   * api.getAccounts({accountType: "host", own: "bob@nqminds.com"})
   */
  getAccounts(filter) {
    const request = buildQueryRequest.call(this, "accounts", filter);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getAccounts: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getAccounts"));
  }

  /**
   * Performs an aggregate query on the given dataset resource, returning a response object with stream in the body
   * @param  {string} datasetId - The id of the dataset-based resource to perform the aggregate query on.
   * @param  {object|string} pipeline - The aggregate pipeline, as defined in the
   * [mongodb docs](https://docs.mongodb.com/manual/aggregation/). Can be given as a JSON object or as a stringified
   * JSON object.
   * @param  {boolean} [ndJSON] - If set, the data is sent in [newline delimited json format](http://ndjson.org/).
   * @return  {object} - Response object, where the response body is a stream object.
   */
  getAggregateDataStream(datasetId, pipeline, ndJSON) {
    // Convert pipeline to string if necessary.
    if (pipeline && typeof pipeline === "object") {
      pipeline = JSON.stringify(pipeline);
    } else {
      // Decode client-provided string to avoid possible double-encoding.
      pipeline = decodeURIComponent(pipeline);
    }
    pipeline = encodeURIComponent(pipeline);

    const endpoint = `resources/${datasetId}/${ndJSON ? "ndaggregate" : "aggregate"}?pipeline=${pipeline}`;
    const request = buildQueryRequest.call(this, endpoint);
    return fetch.call(this, request).catch((err) => {
      errLog("TDXApi.getAggregateData: %s", err.message);
      return Promise.reject(new Error(`${err.message} - [network error]`));
    });
  }

  /**
   * Performs an aggregate query on the given dataset resource.
   * @param  {string} datasetId - The id of the dataset-based resource to perform the aggregate query on.
   * @param  {object|string} pipeline - The aggregate pipeline, as defined in the
   * [mongodb docs](https://docs.mongodb.com/manual/aggregation/). Can be given as a JSON object or as a stringified
   * JSON object.
   * @param  {boolean} [ndJSON] - If set, the data is sent in [newline delimited json format](http://ndjson.org/).
   * @return  {DatasetData}
   */
  getAggregateData(datasetId, pipeline, ndJSON) {
    return this.getAggregateDataStream(datasetId, pipeline, ndJSON).then(checkResponse.bind(this, "getAggregateData"));
  }

  /**
   * Gets details of the currently authenticated account.
   * @return  {object} - Details of the authenticated account.
   */
  getAuthenticatedAccount() {
    const request = buildQueryRequest.call(this, "auth-account");
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getAuthenticatedAccount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getAuthenticatedAccount"));
  }

  /**
   * Gets all data from the given dataset resource that matches the filter provided and returns a response object with
   * stream in the body.
   * @param  {string} datasetId - The id of the dataset-based resource.
   * @param  {object} [filter] - A mongodb filter object. If omitted, all data will be retrieved.
   * @param  {object} [projection] - A mongodb projection object. Should be used to restrict the payload to the
   * minimum properties needed if a lot of data is being retrieved.
   * @param  {GetDataOptions} [options] - A mongodb options object. Can be used to limit, skip, sort etc. Note a default
   * `limit` of 1000 is applied if none is given here.
   * @param  {boolean} [ndJSON] - If set, the data is sent in [newline delimited json format](http://ndjson.org/).
   * @return  {object} - Response object, where the response body is a stream object.
   */
  getDataStream(datasetId, filter, projection, options, ndJSON) {
    const endpoint = `resources/${datasetId}/${ndJSON ? "nddata" : "data"}`;
    const request = buildQueryRequest.call(this, endpoint, filter, projection, options);
    return fetch.call(this, request).catch((err) => {
      errLog("TDXApi.getDataStream: %s", err.message);
      return Promise.reject(new Error(`${err.message} - [network error]`));
    });
  }

  /**
   * For structured resources, e.g. datasets, this function gets all data from the given dataset resource that
   * matches the filter provided.
   *
   * For non-structured resources such as text-content or raw files etc only the `datasetId` argument is relevant
   * and this method is equivalent to `downloadResource`.
   *
   * @param  {string} datasetId - The id of the dataset-based resource.
   * @param  {object} [filter] - A mongodb filter object. If omitted, all data will be retrieved.
   * @param  {object} [projection] - A mongodb projection object. Should be used to restrict the payload to the
   * minimum properties needed if a lot of data is being retrieved.
   * @param  {GetDataOptions} [options] - A mongodb options object. Can be used to limit, skip, sort etc. Note a default
   * `limit` of 1000 is applied if none is given here.
   * @param  {boolean} [ndJSON] - If set, the data is sent in [newline delimited json format](http://ndjson.org/).
   * @return  {DatasetData}
   */
  getData(datasetId, filter, projection, options, ndJSON) {
    return this.getDataStream(datasetId, filter, projection, options, ndJSON).then(checkResponse.bind(this, "getData"));
  }

  /**
   * Sugar for newline delimited data. See `getData` for details.
   */
  getNDData(datasetId, filter, projection, options) {
    return this.getDataStream(datasetId, filter, projection, options, true).then(checkResponse.bind(this, "getNDData"));
  }

  /**
   * @deprecated  use {@link TDXApi#getDataStream}
   * Gets all data from the given dataset resource that matches the filter provided and returns a response object with
   * stream in the body.
   * @param  {string} datasetId - The id of the dataset-based resource.
   * @param  {object} [filter] - A mongodb filter object. If omitted, all data will be retrieved.
   * @param  {object} [projection] - A mongodb projection object. Should be used to restrict the payload to the
   * minimum properties needed if a lot of data is being retrieved.
   * @param  {GetDataOptions} [options] - A mongodb options object. Can be used to limit, skip, sort etc. Note a default
   * `limit` of 1000 is applied if none is given here.
   * @param  {boolean} [ndJSON] - If set, the data is sent in [newline delimited json format](http://ndjson.org/).
   * @return  {object} - Response object, where the response body is a stream object.
   */
  getDatasetDataStream(datasetId, filter, projection, options, ndJSON) {
    return this.getDataStream(datasetId, filter, projection, options, ndJSON);
  }

  /**
   * @deprecated  use {@link TDXApi#getData}
   * Gets all data from the given dataset resource that matches the filter provided.
   * @param  {string} datasetId - The id of the dataset-based resource.
   * @param  {object} [filter] - A mongodb filter object. If omitted, all data will be retrieved.
   * @param  {object} [projection] - A mongodb projection object. Should be used to restrict the payload to the
   * minimum properties needed if a lot of data is being retrieved.
   * @param  {GetDataOptions} [options] - A mongodb options object. Can be used to limit, skip, sort etc. Note a default
   * `limit` of 1000 is applied if none is given here.
   * @param  {boolean} [ndJSON] - If set, the data is sent in [newline delimited json format](http://ndjson.org/).
   * @return  {DatasetData}
   */
  getDatasetData(datasetId, filter, projection, options, ndJSON) {
    return this.getData(datasetId, filter, projection, options, ndJSON);
  }

  /**
   * Gets a count of the data in a dataset-based resource, after applying the given filter.
   * @param  {string} datasetId - The id of the dataset-based resource.
   * @param  {object} [filter] - An optional mongodb filter to apply before counting the data.
   */
  getDataCount(datasetId, filter) {
    const request = buildQueryRequest.call(this, `resources/${datasetId}/count`, filter);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getDataCount: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getDataCount"));
  }

  /**
   * @deprecated  use {@link TDXApi#getDataCount}
   * Gets a count of the data in a dataset-based resource, after applying the given filter.
   * @param  {string} datasetId - The id of the dataset-based resource.
   * @param  {object} [filter] - An optional mongodb filter to apply before counting the data.
   */
  getDatasetDataCount(datasetId, filter) {
    return this.getDataCount(datasetId, filter);
  }

  /**
   * Gets a list of distinct values for a given property in a dataset-based resource.
   * @param  {string} datasetId - The id of the dataset-based resource.
   * @param  {string} key - The name of the property to use. Can be a property path, e.g. `"address.postcode"`.
   * @param  {object} [filter] - An optional mongodb filter to apply.
   * @return  {object[]} - The distinct values.
   */
  getDistinct(datasetId, key, filter, projection, options) {
    const request = buildQueryRequest.call(
      this,
      `resources/${datasetId}/distinct?key=${key}`,
      filter,
      projection,
      options
    ); // eslint-disable-line max-len
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getDistinct: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getDistinct"));
  }

  /**
   * Gets the details for a given resource id.
   * @param  {string} resourceId - The id of the resource to retrieve.
   * @param  {boolean} [noThrow=false] - If set, the call won't reject or throw if the resource doesn't exist.
   * @return  {Resource}
   * @exception  Will throw if the resource is not found (see `noThrow` flag) or permission is denied.
   * @example
   * api.getResource(myResourceId)
   *  .then((resource) => {
   *    console.log(resource.name);
   *  });
   */
  getResource(resourceId, noThrow) {
    const request = buildQueryRequest.call(this, `resources/${resourceId}`);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getResource: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then((response) => {
        if (noThrow) {
          // If noThrow specified, return null if there is an error fetching the resource, rather than throwing.
          if (response.ok) {
            return response.json();
          } else if (response.status === 404) {
            return null;
          } else {
            return checkResponse.call(this, "getResource", response);
          }
        } else {
          return checkResponse.call(this, "getResource", response);
        }
      });
  }

  /**
   * Gets all access the authenticated account has to the given resource id.
   * @param  {string} resourceId - The id of the resource whose access is to be retrieved.
   * @return {ResourceAccess[]} - Array of ResourceAccess objects.
   * @example
   * api.getResourceAccess(myResourceId)
   *  .then((resourceAccess) => {
   *    console.log("length of access list: ", resourceAccess.length);
   *  });
   */
  getResourceAccess(resourceId, filter, projection, options) {
    const request = buildQueryRequest.call(this, `resources/${resourceId}/access`, filter, projection, options);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getResourceAccess: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then((response) => {
        return checkResponse.call(this, "getResourceAccess", response);
      });
  }

  /**
   * Gets all resources that are ancestors of the given resource.
   * @param  {string} resourceId - The id of the resource whose parents are to be retrieved.
   * @return  {Resource[]}
   */
  getResourceAncestors(resourceId) {
    const request = buildQueryRequest.call(this, `resources/${resourceId}/ancestors`);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getDatasetAncestors: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getResourceAncestors"));
  }

  /**
   * Gets the details of all resources that match the given filter.
   * @param  {object} [filter] - A mongodb filter definition. Note, filtering on `id` is restricted to an explicit
   * string match or a `$in` clause to match against a list of ids.
   * @param  {object} [projection] - A mongodb projection definition, can be used to restrict which properties are
   * returned thereby limiting the payload.
   * @param  {object} [options] - A mongodb options definition, can be used for limit, skip, sorting etc.
   * @return  {Resource[]}
   * @example <caption>filtering by explicit id</caption>
   * tdxApi.getResources({"id": "rygq8DNEPw"});
   * @example <caption>filtering by set of ids</caption>
   * tdxApi.getResources({"id": {"$in": ["rygq8DNEPw", "xmm1z-D8d"]}});
   * @example <caption>filtering by name</caption>
   * tdxApi.getResources({"name": {"$regex": "test"}});
   * @example <caption>filtering by name, returning only id and name</caption>
   * tdxApi.getResources({"name": {"$regex": "test"}}, {"id": 1, "name": 1});
   */
  getResources(filter, projection, options) {
    const request = buildQueryRequest.call(this, "resources", filter, projection, options);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getResource: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getResources"));
  }

  /**
   * Retrieves all resources that have an immediate ancestor of the given schema id.
   * @param  {string} schemaId - The id of the schema to match, e.g. `"geojson"`.
   * @return  {Resource[]}
   */
  getResourcesWithSchema(schemaId) {
    const filter = {"schemaDefinition.parent": schemaId};
    return this.getResources(filter);
  }

  /**
   * Retrieves an authorisation token for the given TDX instance
   * @param  {string} tdx - The TDX instance name, e.g. `"tdx.acme.com"`.
   * @return  {string}
   */
  getTDXToken(tdx) {
    const request = buildQueryRequest.call(this, `tdx-token/${tdx}`);
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.getTDXToken: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "getTDXToken"));
  }

  /**
   * Gets the details for a given zone (account) id.
   * @param  {string} accountId - the id of the zone to be retrieved.
   * @return  {Zone} zone
   */
  getZone(accountId) {
    return this.getAccount(accountId);
  }

  /**
   * Determines if the given account is a member of the given group.
   * Note that accurate visibility of group members requires one or more of the
   * following to be true:
   * - the authenticated account added `accountId` to the group
   * - the authenticated account owns the group
   * @param {string} accountId - the id of the account
   * @param {*} groupId - the id of the group
   * @return {boolean} - `true` if `accountId` is a member of the group and visible to the authenticated account.
   */
  isInGroup(accountId, groupId) {
    const lookup = {
      aid: accountId,
      "r.0": {$exists: true},
      grp: "m",
    };
    return this.getResourceAccess(groupId, lookup).then((access) => {
      return !!access.length;
    });
  }

  /**
   * Validates the given token was signed by this TDX, and returns the decoded token data.
   * @param  {string} token - The TDX auth server token to validate.
   * @param  {string} [ip] - The optional IP address to validate against.
   * @return  {object} - The decoded token data.
   */
  validateTDXToken(token, ip) {
    const request = buildQueryRequest.call(this, "token/validate", {token, ip});
    return fetch
      .call(this, request)
      .catch((err) => {
        errLog("TDXApi.validateTDXToken: %s", err.message);
        return Promise.reject(new Error(`${err.message} - [network error]`));
      })
      .then(checkResponse.bind(this, "validateTDXToken"));
  }
}

export default TDXApi;