'use strict';
const axios = require('axios');
const _ = require('lodash');
const fs = require('fs');
const FormData = require('form-data');
const { Document } = require('marpat');
const { Connection } = require('./connection.model');
const { Credentials } = require('./credentials.model');
const { Data } = require('./data.model');
/**
* @class Filemaker
* @classdesc The class used to integrate with the FileMaker server Data API
*/
class Filemaker extends Document {
constructor(data) {
super();
this.schema({
/**
* The version of Data API to use.
* @member Filemaker#version
* @type String
*/
version: {
type: String,
required: true,
default: '1'
},
/**
* The client application name.
* @member Filemaker#application
* @type String
*/
application: {
type: String,
required: true
},
/**
* The client application server.
* @member Filemaker#server
* @type String
*/
server: {
type: String,
required: true
},
/** The client data logger.
* @public
* @member Filemaker#data
* @type Object
*/
data: {
type: Data,
required: true
},
/** The client application connection object.
* @public
* @member Filemaker#connection
* @type Object
*/
connection: {
type: Connection,
required: true
}
});
}
/**
* preInit is a hook
* @schema
* @return {null} The preInit hook does not return anything
*/
preInit(data) {
this.data = Data.create();
this.connection = Connection.create({
server: data.server,
application: data.application,
user: data.user,
password: data.password
});
}
/**
* Generates a url for use when creating a record.
* @private
* @param {String} layout The layout to use when creating a record.
* @return {String} A URL
*/
_createURL(layout) {
let url = `${this.server}/fmi/data/v1/databases/${
this.application
}/layouts/${layout}/records`;
return url;
}
/**
* Generates a url for use when updating a record.
* @private
* @param {String} layout The layout to use when updating a record.
* @param {String} recordId The FileMaker internal record id to use.
* @return {String} A URL
*/
_updateURL(layout, recordId) {
let url = `${this.server}/fmi/data/v1/databases/${
this.application
}/layouts/${layout}/records/${recordId}`;
return url;
}
/**
* Generates a url for use when deleting a record.
* @private
* @param {String} layout The layout to use when creating a record.
* @param {String} recordId The FileMaker internal record id to use.
* @return {String} A URL
*/
_deleteURL(layout, recordId) {
let url = `${this.server}/fmi/data/v1/databases/${
this.application
}/layouts/${layout}/records/${recordId}`;
return url;
}
/**
* Generates a url to access a record.
* @private
* @param {String} layout The layout to use when acessing a record.
* @param {String} recordId The FileMaker internal record id to use.
* @return {String} A URL
*/
_getURL(layout, recordId) {
let url = `${this.server}/fmi/data/v1/databases/${
this.application
}/layouts/${layout}/records/${recordId}`;
return url;
}
/**
* Generates a url for use when listing records.
* @private
* @param {String} layout The layout to use when listing records.
* @return {String} A URL
*/
_listURL(layout) {
let url = `${this.server}/fmi/data/v1/databases/${
this.application
}/layouts/${layout}/records`;
return url;
}
/**
* Generates a url for use when performing a find request.
* @private
* @param {String} layout The layout to use when listing records.
* @return {String} A URL
*/
_findURL(layout) {
let url = `${this.server}/fmi/data/v1/databases/${
this.application
}/layouts/${layout}/_find`;
return url;
}
/**
* Generates a url for use when setting globals.
* @private
* @param {String} layout The layout to use when setting globals.
* @return {String} A URL
*/
_globalsURL() {
let url = `${this.server}/fmi/data/v1/databases/${
this.application
}/globals`;
return url;
}
/**
* Generates a url for use when uploading files to FileMaker containers.
* @private
* @param {String} layout The layout to use when setting globals.
* @param {String} recordId the record id to use when inserting the file.
* @param {String} fieldName the field to use when inserting a file.
* @param {String} fieldRepetition The repetition to use when inserting the file.
* default is 1.
* @return {String} A URL
*/
_uploadURL(layout, recordId, fieldName, fieldRepetition = 1) {
let url = `${this.server}/fmi/data/v1/databases/${
this.application
}/layouts/${layout}/records/${recordId}/containers/${fieldName}/${fieldRepetition}`;
return url;
}
/**
* @method _sanitizeParameters
* @memberof Filemaker
* @private
* @description stringifys all values for an object. This is used to ensure that find requests and list requests
* can use either strings or numbers when setting options.
* @return {Object} returns an object with all values mapped to strings.
*/
_sanitizeParameters(parameters) {
return _.mapValues(
parameters,
value => (_.isNumber(value) ? value.toString() : value)
);
}
/**
* @method authenticate
* @memberof Filemaker
* @public
* @description Checks the private connection schema for a token and if the current time is between when that token was
* issued and when it will expire. If the connection token is not a string (its empty) or the current time is
* not between when the token is issued and the time it will expire this method calls the private
* is returned this promise method will reject.
* @see {@method Connnection#generate}
* @return {Promise} returns a promise that will either resolve or reject based on the Data API.
*
*/
authenticate() {
return new Promise((resolve, reject) => {
if (this.connection.valid()) {
resolve(this.connection.token);
} else {
this.connection
.generate()
.then(token => {
this.save();
return token;
})
.then(token => resolve(token))
.catch(error => reject(error));
}
});
}
/**
* @method saveState
* @private
* @memberof Filemaker
* @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.
*
*/
_saveState(response) {
this.save();
return response;
}
/**
* @method create
* @public
* @memberof Filemaker
* @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 new Promise((resolve, reject) =>
this.authenticate()
.then(token =>
axios({
url: this._createURL(layout),
method: 'post',
headers: {
authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: Object.assign(parameters, {
fieldData: this._stringify(data)
})
})
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(body => this._filterResponse(body))
.then(response => resolve(response))
.catch(error => reject(error.response.data.messages[0]))
);
}
/**
* @method edit
* @public
* @memberof Filemaker
* @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 new Promise((resolve, reject) =>
this.authenticate()
.then(token =>
axios({
url: this._updateURL(layout, recordId),
method: 'patch',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: Object.assign(this._sanitizeParameters(parameters), {
fieldData: this._stringify(data)
})
})
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(body => this._filterResponse(body))
.then(response => resolve(response))
.catch(error => reject(error.response.data.messages[0]))
);
}
/**
* @method delete
* @public
* @memberof Filemaker
* @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.
* @return {Promise} returns a promise that will either resolve or reject based on the Data API.
*
*/
delete(layout, recordId, parameters = {}) {
return new Promise((resolve, reject) =>
this.authenticate()
.then(token =>
axios({
url: this._deleteURL(layout, recordId),
method: 'delete',
headers: {
Authorization: `Bearer ${token}`
},
data: this._sanitizeParameters(parameters) || {}
})
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(body => this._filterResponse(body))
.then(response => resolve(response))
.catch(error => reject(error.response.data.messages[0]))
);
}
/**
* @method get
* @public
* @memberof Filemaker
* @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 new Promise((resolve, reject) =>
this.authenticate()
.then(token =>
axios({
url: this._getURL(layout, recordId),
method: 'get',
headers: {
Authorization: `Bearer ${token}`
},
params: this._namespace(parameters)
})
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(body => this._filterResponse(body))
.then(response => resolve(response))
.catch(error => reject(error.response.data.messages[0]))
);
}
/**
* @method list
* @public
* @memberof Filemaker
* @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 new Promise((resolve, reject) =>
this.authenticate()
.then(token =>
axios({
url: this._listURL(layout),
method: 'get',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
params: this._namespace(parameters)
})
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(body => this._filterResponse(body))
.then(response => resolve(response))
.catch(error => reject(error.response.data.messages[0]))
);
}
/**
* @method find
* @public
* @memberof Filemaker
* @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.authenticate()
.then(token =>
axios({
url: this._findURL(layout),
method: 'post',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: Object.assign(
{ query: this._toArray(query) },
this._sanitizeParameters(parameters)
)
})
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(body => this._filterResponse(body))
.then(response => resolve(response))
.catch(
error =>
error.response.data.messages[0].code === '401'
? resolve({
data: [],
message: this._filterResponse(error.response.data)
})
: reject(error.response.data.messages[0])
)
);
}
/**
* @method globals
* @public
* @memberof Filemaker
* @description Sets global fields for the current session.
* @param {Object|Array} data a json object containing the name value pairs to set.
* @return {Promise} returns a promise that will either resolve or reject based on the Data API.
*/
globals(data) {
return new Promise((resolve, reject) =>
this.authenticate()
.then(token =>
axios({
url: this._globalsURL(),
method: 'patch',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: { globalFields: JSON.stringify(data) }
})
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(body => resolve(body.response))
.catch(error => reject(error.response.data.messages[0]))
);
}
/**
* @method upload
* @public
* @memberof Filemaker
* @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 {Number} fieldRepetition The field repetition to use when inserting into a container field.
* by default this is 1.
* @return {Promise} returns a promise that will either resolve or reject based on the Data API.
*/
upload(file, layout, containerFieldName, recordId = 0, fieldRepetition = 1) {
return new Promise((resolve, reject) => {
let form = new FormData();
let resolveRecordId = () =>
recordId === 0
? this.create(layout, {}).then(response => response.recordId)
: Promise.resolve(recordId);
form.append('upload', fs.createReadStream(file));
resolveRecordId()
.then(recordId =>
this.authenticate().then(token =>
axios.post(
this._uploadURL(
layout,
recordId,
containerFieldName,
fieldRepetition
),
form,
{
headers: {
...form.getHeaders(),
Authorization: `Bearer ${token}`
}
}
)
)
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(body => this._filterResponse(body))
.then(response => resolve(response))
.catch(
error =>
error.errno !== undefined
? reject(error.message)
: reject(error.response.data.messages[0])
);
});
}
/**
* @method script
* @public
* @memberof Filemaker
* @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} name The name of the script
* @param {String} layout The layout to use for the list request
* @param {Object} parameters Parameters to pass to the script
* @return {Promise} returns a promise that will either resolve or reject based on the Data API.
*/
script(name, layout, parameters = {}) {
return new Promise((resolve, reject) =>
this.authenticate()
.then(token =>
axios({
url: this._listURL(layout),
method: 'get',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
params: this._sanitizeParameters(
Object.assign(
{ script: name, 'script.param': this._stringify(parameters) },
this._namespace({ limit: '1' })
)
)
})
)
.then(response => response.data)
.then(body => this.data.outgoing(body))
.then(body => this.connection.extend(body))
.then(body => this._saveState(body))
.then(
body =>
body.response.scriptError === '0'
? resolve({
result: this._isJson(body.response.scriptResult)
? JSON.parse(body.response.scriptResult)
: body.response.scriptResult
})
: reject({
result: this._isJson(body.response.scriptResult)
? JSON.parse(body.response.scriptResult)
: body.response.scriptResult
})
)
.catch(error => reject(error.response.data.messages[0]))
);
}
/**
/**
* @method _toArray
* @private
* @memberof Filemaker
* @description _toArray is a helper method that converts an object into an array. This is used
* @param {Object|Array} data the raw data returned from a filemaker. This can be an array or an object.
* @return {Object} a json object containing stringified data.
*/
_toArray(data) {
return Array.isArray(data) ? data : [data];
}
/**
* @method _stringify
* @private
* @memberof Filemaker
* @description _stringify is a helper method that converts numbers and objects / arrays to strings.
* @param {Object|Array} The data being used to create or update a record.
* @return {Object} a json object containing stringified data.
*/
_stringify(data) {
return _.mapValues(
this.data.incoming(data),
value =>
typeof value === 'string'
? value
: typeof value === 'object'
? JSON.stringify(value)
: typeof value === 'number' ? value.toString() : ''
);
}
/**
* @method _filterResponse
* @private
* @memberof Filemaker
* @description This method filters the FileMaker DAPI response by testing if a script was triggered with
* the request, then either selecting the response, script error, and script result from the response or
* selecting just the response.
* @return {Object} a json object containing the selected data from the Data API Response.
*/
_filterResponse(data) {
return data.scriptError
? _.chain(data)
.map(object =>
_.pick(object, ['response', 'scriptError', 'scriptResult'])
)
.mapValues(value => (this._isJson(value) ? JSON.parse(value) : value))
: _.mapValues(
data.response,
value => (this._isJson(value) ? JSON.parse(value) : value)
);
}
/**
* @method _isJson
* @private
* @memberof Filemaker
* @description This is a helper method for the _filterResponse method.
* @return {Boolean} a boolean result if the data passed to it is json
*/
_isJson(data) {
try {
JSON.parse(data);
} catch (e) {
return false;
}
return true;
}
/**
* @method _namespace
* @private
* @memberof Filemaker
* @description This method filters the FileMaker DAPI response by testing if a script was triggered with
* the request, then either selecting the response, script error, and script result from the response or
* selecting just the response.
* @return {Object} a json object containing the selected data from the Data API Response.
*/
_namespace(data) {
let underscored = ['limit', 'offset', 'sort'];
return _.mapKeys(
data,
(value, key) => (_.includes(underscored, key) ? `_${key}` : key)
);
}
/**
* @method fieldData
* @public
* @memberof Filemaker
* @description fieldData is a helper method that strips the filemaker structural layout and portal information
* from a record. It returns only the data contained in the fieldData key and the recordId.
* @param {Object|Array} data the raw data returned from a filemaker. This can be an array or an object.
* @return {Object} a json object containing fieldData from the record.
*/
fieldData(data) {
return Array.isArray(data)
? _.map(data, object =>
Object.assign({}, object.fieldData, {
recordId: object.recordId,
modId: object.modId
})
)
: Object.assign(data.fieldData, {
recordId: data.recordId,
modId: data.modId
});
}
/**
* @method recordId
* @public
* @memberof Filemaker
* @description returns record ids for the data parameters passed to it. This can be an array of ids or an object.
* from a record. It returns only the data contained in the fieldData key adn the recordId.
* @param {Object|Array} data the raw data returned from a filemaker. This can be an array or an object.
* @return {Object} a json object containing fieldData from the record.
*/
recordId(data) {
return Array.isArray(data)
? _.map(data, object => object.recordId)
: data.recordId.toString();
}
}
/**
* @module Filemaker
*/
module.exports = {
Filemaker
};