models/client.model.js

'use strict';

const fs = require('fs');
const _ = require('lodash');
const FormData = require('form-data');
const intoStream = require('into-stream');
const { Document } = require('marpat');
const { Data } = require('./data.model');
const { Agent } = require('./agent.model');
const {
  toArray,
  namespace,
  isJSON,
  isEmpty,
  toStrings,
  sanitizeParameters,
  pick,
  parseScriptResult,
  setData,
  urls
} = require('../utilities');

const { productInfo, databases } = require('../services');

/**
 * @global
 */
global.FMS_API_CLIENT = {};

/**
 * @class Client
 * @classdesc The class used to integrate with the FileMaker server Data API
 */
class Client extends Document {
  /** @constructs */
  constructor() {
    super();
    this.schema({
      /**
       * A name for the client.
       * @member Client#name
       * @type String
       */
      name: {
        type: String
      },
      /** The client data logger.
       * @public
       * @member Client#data
       * @type Object
       */
      data: {
        type: Data,
        required: true
      },

      /** The client agent object.
       * @public
       * @member Client#agent
       * @type Object
       */
      agent: {
        type: Agent,
        required: true
      }
    });
  }
  /**
   * preInit is a hook
   * @schema
   * @description The client preInit hook  creates a data embedded document and a connection
   * embedded document on create.
   * @param {Object} data The data used to create the client.
   */
  preInit(data) {
    const {
      agent,
      timeout,
      concurrency,
      threshold,
      usage,
      proxy,
      ...connection
    } = data;
    const protocol = data.server.startsWith('https') ? 'https' : 'http';
    this.data = Data.create({ track: usage === undefined });
    this.agent = Agent.create({
      agent,
      proxy,
      timeout,
      threshold,
      concurrency,
      protocol,
      connection
    });
  }

  /**
   * preDelete is a hook
   * @schema
   * @description The client delete hook ensures a client attempts to log out before it is destroyed.
   * @param {Object} data The data used to create the client.
   * @return {null} The delete hook does not return anything.
   */
  preDelete() {
    return new Promise((resolve, reject) =>
      this.agent.connection
        .end()
        .then(response => resolve())
        .catch(error => resolve(error))
    );
  }

  /**
   * @method destroy
   * @memberof Client
   * @public
   * @description The destroy method is tied to the base model's
   * delete method method. This allows you to delete a client.
   * @see {@link https://github.com/luidog/marpat#deleting}
   * @return {Promise} returns a promise inherited by the clients bas class
   */
  destroy() {
    return super.delete();
  }

  /**
   * @method login
   * @memberof Client
   * @public
   * @description creates a session with the Data API and returns a token.
   * @see {@method Client#authenticate}
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.

   */
  login() {
    return this.agent.connection.start(
      !_.isEmpty(this.agent.agent) ? this.agent.localize() : false
    );
  }

  /**
   * @method logout
   * @memberof Client
   * @public
   * @description logs out of the current authentication session and clears the saved token.
   * @param {String} [id] the connection id to logout.
   * @see {@method Connnection#end}
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  logout(id) {
    return this.agent.connection
      .end(!_.isEmpty(this.agent.agent) ? this.agent.localize() : false, id)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body));
  }
  /**
   * @method productInfo
   * @memberof Client
   * @public
   * @description Retrieves information about the FileMaker Server or FileMaker Cloud host.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  productInfo() {
    return productInfo(
      this.agent.connection.server,
      this.agent.connection.version
    );
  }

  /**
   * @method status
   * @memberof Client
   * @public
   * @description Compiles Data API Client status information.
   * @return {Promise} returns a promise that will either resolve with client status information
   */
  status() {
    return new Promise((resolve, reject) =>
      resolve({
        ...this.data.status(),
        queue: this.agent.queue.map(({ url }) => ({ url })),
        pending: this.agent.pending.map(({ url }) => ({ url })),
        sessions: this.agent.connection.sessions.map(
          ({ issued, expires, id, active }) => ({
            issued,
            expires,
            id,
            active
          })
        )
      })
    );
  }

  /**
   * @method reset
   * @memberof Client
   * @public
   * @description Resets the client, clearing pending and queued requests and DAPI sessions.
   * @return {Promise} returns a promise that will either with a message object.
   */
  reset() {
    return new Promise((resolve, reject) => {
      const logouts = [];
      this.agent.queue = [];
      this.agent.pending = [];
      this.agent.connection.sessions.forEach(({ id }) =>
        logouts.push(this.logout(id))
      );
      Promise.all(logouts)
        .then(results => {
          return this.save();
        })
        .then(client =>
          resolve({
            message: 'Client Reset'
          })
        )
        .catch(error => reject({ message: 'Client Reset Failed' }));
    });
  }

  /**
   * @method databases
   * @memberof Client
   * @public
   * @description Retrieves information about the FileMaker Server's hosted databases.
   * @see Metadata Service#databases
   * @param {Object} [credentials] Credentials to use when listing server databases
   * @param {String} [credentials.user='configured user'] Credentials to use when listing server databases
   * @param {String} [credentials.password='configured password'] Credentials to use when listing server databases
   * @param {String} version The API version to use when gathering databases.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  databases(credentials, version) {
    return databases(
      this.agent.connection.server,
      credentials || this.agent.connection.credentials,
      this.agent.connection.version
    );
  }

  /**
   * @method layouts
   * @memberof Client
   * @public
   * @description Retrieves information about the FileMaker Server's hosted databases.
   * @param {Object} [parameters] optional request parameters for the request.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  layouts(parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.layouts(
            this.agent.connection.server,
            this.agent.connection.database,
            this.agent.connection.version
          ),
          method: 'get'
        },
        parameters
      )
      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => body.response);
  }

  /**
   * @method scripts
   * @memberof Client
   * @public
   * @description Retrieves information about the FileMaker Server's hosted databases.
   * @param {Object} [parameters] optional request parameters for the request.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  scripts(parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.scripts(
            this.agent.connection.server,
            this.agent.connection.database,
            this.agent.connection.version
          ),
          method: 'get'
        },
        parameters
      )

      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => body.response);
  }

  /**
   * @method layout
   * @memberof Client
   * @public
   * @description Retrieves information about the FileMaker Server's hosted databases.
   * @param {String} layout The layout to use in the request.
   * @param {Object} [parameters] optional request parameters for the request.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  layout(layout, parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.layout(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            this.agent.connection.version
          ),
          method: 'get',
          params: toStrings(sanitizeParameters(parameters, ['recordId']))
        },
        parameters
      )
      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => body.response);
  }

  /**
   * @method duplicate
   * @memberof Client
   * @public
   * @description Retrieves information about the FileMaker Server's hosted databases.
   * @param {String} layout The layout to use in the request.
   * @param {String} recordId The record id to target for duplication.
   * @param {Object} [parameters] optional request parameters for the request.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  duplicate(layout, recordId, parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.duplicate(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            recordId,
            this.agent.connection.version
          ),
          method: 'post',
          headers: {
            'Content-Type': 'application/json'
          },
          data: sanitizeParameters(parameters, [
            'script',
            'script.param',
            'script.prerequest',
            'script.prerequest.param',
            'script.presort',
            'script.presort.param',
            'request'
          ])
        },
        parameters
      )

      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => parseScriptResult(body));
  }

  /**
   * @method _save
   * @private
   * @memberof Client
   * @description Triggers a save and returns the response. This is responsible for ensuring the documents are up to date.
   * @param {Any} response The response data from the data api request.
   * @return {Any} Returns the umodified response.
   */
  _save(response) {
    this.save();
    return response;
  }

  /**
   * @method create
   * @public
   * @memberof Client
   * @description Creates a record in FileMaker. This method accepts a layout variable and a data variable.
   * @param {String} layout The layout to use when creating a record.
   * @param {Object} data The data to use when creating a record.
   * @param {Object} parameters The request parameters to use when creating the record.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  create(layout, data = {}, parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.create(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            this.agent.connection.version
          ),
          method: 'post',
          headers: {
            'Content-Type': 'application/json'
          },
          data: Object.assign(
            sanitizeParameters(parameters, [
              'portalData',
              'script',
              'script.param',
              'script.prerequest',
              'script.prerequest.param',
              'script.presort',
              'script.presort.param',
              'request'
            ]),
            this.data.incoming(setData(data))
          )
        },
        parameters
      )

      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => parseScriptResult(body))
      .then(response =>
        parameters.merge ? Object.assign(data, response) : response
      );
  }

  /**
   * @method edit
   * @public
   * @memberof Client
   * @description Edits a filemaker record.
   * @param {String} layout The layout to use when editing the record.
   * @param {String} recordId The FileMaker internal record ID to use when editing the record.
   * @param {Object} data The data to use when editing a record.
   * @param {Object} parameters parameters to use when performing the query.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  edit(layout, recordId, data, parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.update(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            recordId,
            this.agent.connection.version
          ),
          method: 'patch',
          headers: {
            'Content-Type': 'application/json'
          },
          data: Object.assign(
            sanitizeParameters(parameters, [
              'portalData',
              'modId',
              'script',
              'script.param',
              'script.prerequest',
              'script.prerequest.param',
              'script.presort',
              'script.presort.param',
              'request'
            ]),
            this.data.incoming(setData(data))
          )
        },
        parameters
      )

      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => parseScriptResult(body))
      .then(body =>
        parameters.merge
          ? Object.assign(data, { recordId: recordId }, body)
          : body
      );
  }

  /**
   * @method delete
   * @public
   * @memberof Client
   * @description Deletes a filemaker record.
   * @param {String} layout The layout to use when deleting the record.
   * @param {String} recordId The FileMaker internal record ID to use when editing the record.
   * @param {Object} parameters parameters to use when performing the query.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  delete(layout, recordId, parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.delete(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            recordId,
            this.agent.connection.version
          ),
          method: 'delete',
          data: sanitizeParameters(parameters, [
            'script',
            'script.param',
            'script.prerequest',
            'script.prerequest.param',
            'script.presort',
            'script.presort.param',
            'request'
          ])
        },
        parameters
      )
      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => parseScriptResult(body));
  }

  /**
   * @method get
   * @public
   * @memberof Client
   * @description Retrieves a filemaker record based upon the layout and recordId.
   * @param {String} layout The layout to use when retrieving the record.
   * @param {String} recordId The FileMaker internal record ID to use when retrieving the record.
   * @param {Object} [parameters] Parameters to add for the get query.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  get(layout, recordId, parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.get(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            recordId,
            this.agent.connection.version
          ),
          method: 'get',
          params: toStrings(
            sanitizeParameters(namespace(parameters), [
              'script',
              'script.param',
              'script.prerequest',
              'script.prerequest.param',
              'script.presort',
              'script.presort.param',
              'layout.response',
              'portal',
              '_offset.*',
              '_limit.*',
              'request'
            ])
          )
        },
        parameters
      )
      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => parseScriptResult(body));
  }

  /**
   * @method list
   * @public
   * @memberof Client
   * @description Retrieves a list of FileMaker records based upon a layout.
   * @param {String} layout The layout to use when retrieving the record.
   * @param {Object} [parameters] the parameters to use to modify the query.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  list(layout, parameters = {}) {
    return this.agent
      .request(
        {
          url: urls.list(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            this.agent.connection.version
          ),
          method: 'get',
          headers: {
            'Content-Type': 'application/json'
          },
          params: toStrings(
            sanitizeParameters(namespace(parameters), [
              '_limit',
              '_offset',
              '_sort',
              'portal',
              'script',
              'script.param',
              'script.prerequest',
              'script.prerequest.param',
              'script.presort',
              'script.presort.param',
              'layout.response',
              '_offset.*',
              '_limit.*',
              'request'
            ])
          )
        },
        parameters
      )
      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => parseScriptResult(body));
  }

  /**
   * @method find
   * @public
   * @memberof Client
   * @description performs a FileMaker find.
   * @param {String} layout The layout to use when performing the find.
   * @param {Object} query to use in the find request.
   * @param {Object} parameters the parameters to use to modify the query.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  find(layout, query, parameters = {}) {
    return new Promise((resolve, reject) =>
      this.agent
        .request(
          {
            url: urls.find(
              this.agent.connection.server,
              this.agent.connection.database,
              layout,
              this.agent.connection.version
            ),
            method: 'post',
            headers: {
              'Content-Type': 'application/json'
            },
            data: Object.assign(
              { query: toStrings(toArray(query)) },
              sanitizeParameters(parameters, [
                'limit',
                'sort',
                'offset',
                'portal',
                'script',
                'script.param',
                'script.prerequest',
                'script.prerequest.param',
                'script.presort',
                'script.presort.param',
                'layout.response',
                'offset.*',
                'limit.*',
                'request'
              ])
            )
          },
          parameters
        )

        .then(response => response.data)
        .then(body => this.data.outgoing(body))
        .then(body => this._save(body))
        .then(body => parseScriptResult(body))
        .then(response => resolve(response))
        .catch(error => {
          return error.code === '401'
            ? resolve({
                data: [],
                message: 'No records match the request'
              })
            : reject(error);
        })
    );
  }

  /**
   * @method globals
   * @public
   * @memberof Client
   * @description Sets global fields for the current session.
   * @param  {Object|Array} data a json object containing the name value pairs to set.
   * @param {Object} parameters parameters to use when performing the query.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  globals(data, parameters) {
    return this.agent
      .request(
        {
          url: urls.globals(
            this.agent.connection.server,
            this.agent.connection.database,
            this.agent.connection.version
          ),
          method: 'patch',
          headers: {
            'Content-Type': 'application/json'
          },
          data: { globalFields: toStrings(data) }
        },
        parameters
      )
      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => body.response);
  }

  /**
   * @method upload
   * @public
   * @memberof Client
   * @description Allows you to upload a file to a FileMaker record container field. This method
   * currently creates a record for each upload. This method will use fs to read the file at the given
   * path to a stream. If a record Id is not passed to this method a new record will be created.
   * @param  {String} file               The path to the file to upload.
   * @param {String} layout The layout to use when performing the find.
   * @param  {String} containerFieldName The field name to insert the data into. It must be a container field.
   * @param  {Number|String} recordId the recordId to use when uploading the file.
   * @param {Object} parameters parameters to use when performing the query.
   * @return {Promise} returns a promise that will either resolve or reject based on the Data API.
   */
  upload(file, layout, containerFieldName, recordId = 0, parameters = {}) {
    return new Promise((resolve, reject) => {
      let stream;
      const form = new FormData();
      const resolveRecordId = () =>
        recordId === 0
          ? this.create(layout, {}).then(response => response.recordId)
          : Promise.resolve(recordId);

      if (typeof file === 'string') {
        stream = fs.createReadStream(file);
        stream.on('error', error =>
          reject({ message: error.message, code: error.code })
        );
      } else if (!file || !file.name || !file.buffer) {
        reject({
          message: 'A file object must have a name and buffer property',
          code: 117
        });
      } else {
        stream = intoStream(file.buffer);
        stream.name = file.name;
      }

      form.append('upload', stream);
      resolveRecordId()
        .then(resolvedId =>
          this.agent
            .request(
              {
                url: urls.upload(
                  this.agent.connection.server,
                  this.agent.connection.database,
                  layout,
                  resolvedId,
                  containerFieldName,
                  parameters.fieldRepetition,
                  this.agent.connection.version
                ),
                method: 'post',
                data: form,
                headers: {
                  ...form.getHeaders()
                }
              },
              parameters
            )
            .then(response => response.data)
            .then(body => this.data.outgoing(body))
            .then(body => this._save(body))
            .then(body => parseScriptResult(body))
            .then(response => Object.assign(response, { recordId: resolvedId }))
        )
        .then(response => resolve(response))
        .catch(error => reject(error));
    });
  }

  /**
   * @method run
   * @public
   * @memberof Client
   * @description A public method to make triggering a script easier. This method uses the list method with
   * a limit of 1. This is the lightest weight query possible while still allowing for a script to be triggered.
   * For a more robust query with scripts use the find method.
   * @param  {String} layout     The layout to use for the list request
   * @param  {Object|Array} scripts       The name of the script
   * @param  {Object} parameters Parameters to pass to the script
   * @param  {Object} request A request to run alongside the list method.
   * @return {Promise}           returns a promise that will either resolve or reject based on the Data API.
   */
  run(layout, scripts, parameters, request) {
    return this.agent
      .request(
        {
          url: urls.list(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            this.agent.connection.version
          ),
          method: 'get',
          headers: {
            'Content-Type': 'application/json'
          },
          params: sanitizeParameters(
            Object.assign(
              Array.isArray(scripts)
                ? { scripts }
                : isJSON(scripts)
                ? { scripts: [scripts] }
                : { script: scripts },
              typeof scripts === 'string' && typeof parameters !== 'undefined'
                ? { 'script.param': parameters }
                : {},
              namespace({ limit: 1 })
            ),
            [
              'script',
              'script.param',
              'script.prerequest',
              'script.prerequest.param',
              'script.presort',
              'script.presort.param',
              '_limit'
            ]
          )
        },
        typeof scripts === 'string' && typeof parameters !== 'undefined'
          ? request
          : parameters
      )
      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => pick(parseScriptResult(body), 'scriptResult'));
  }

  /**
   * @method script
   * @public
   * @memberof Client
   * @description A public method to make triggering a script easier.
   * @param  {String} layout The layout to use for the list request
   * @param  {String} script The name of the script
   * @param  {Object|String} param Parameter  to pass to the script
   * @param  {Object} [parameters] Optional request parameters.
   * @return {Promise}      returns a promise that will either resolve or reject based on the Data API.
   */
  script(layout, script, param = {}, parameters) {
    return this.agent
      .request(
        {
          url: urls.script(
            this.agent.connection.server,
            this.agent.connection.database,
            layout,
            script,
            this.agent.connection.version
          ),
          method: 'get',
          headers: {
            'Content-Type': 'application/json'
          },
          params: !isEmpty(param)
            ? {
                'script.param': isJSON(param)
                  ? JSON.stringify(param)
                  : param.toString()
              }
            : param
        },
        parameters
      )
      .then(response => response.data)
      .then(body => this.data.outgoing(body))
      .then(body => this._save(body))
      .then(body => ({
        ...body.response,
        scriptResult: isJSON(body.response.scriptResult)
          ? JSON.parse(body.response.scriptResult)
          : body.response.scriptResult
      }));
  }
}

module.exports = {
  Client
};