wazuh-kibana-app/public/kibana-integrations/saved-objects.js
2018-04-18 13:37:37 +02:00

378 lines
12 KiB
JavaScript

/**
* @name SavedObject
*
* NOTE: SavedObject seems to track a reference to an object in ES,
* and surface methods for CRUD functionality (save and delete). This seems
* similar to how Backbone Models work.
*
* This class seems to interface with ES primarily through the es Angular
* service and the saved object api.
*/
import angular from 'angular';
import _ from 'lodash';
import { SavedObjectNotFound } from 'ui/errors';
import MappingSetupProvider from 'ui/utils/mapping_setup';
import { SearchSourceProvider } from 'ui/courier/data_source/search_source';
import { SavedObjectsClientProvider, findObjectByTitle } from 'ui/saved_objects';
import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery.js';
/**
* An error message to be used when the user rejects a confirm overwrite.
* @type {string}
*/
const OVERWRITE_REJECTED = 'Overwrite confirmation was rejected';
/**
* An error message to be used when the user rejects a confirm save with duplicate title.
* @type {string}
*/
const SAVE_DUPLICATE_REJECTED = 'Save with duplicate title confirmation was rejected';
/**
* @param error {Error} the error
* @return {boolean}
*/
function isErrorNonFatal(error) {
if (!error) return false;
return error.message === OVERWRITE_REJECTED || error.message === SAVE_DUPLICATE_REJECTED;
}
export function SavedObjectProvider(Promise, Private, Notifier, confirmModalPromise, indexPatterns) {
const savedObjectsClient = Private(SavedObjectsClientProvider);
const SearchSource = Private(SearchSourceProvider);
const mappingSetup = Private(MappingSetupProvider);
function SavedObject(config) {
if (!_.isObject(config)) config = {};
/************
* Initialize config vars
************/
// type name for this object, used as the ES-type
const esType = config.type;
this.getDisplayName = function () {
return esType;
};
// NOTE: this.type (not set in this file, but somewhere else) is the sub type, e.g. 'area' or
// 'data table', while esType is the more generic type - e.g. 'visualization' or 'saved search'.
this.getEsType = function () {
return esType;
};
/**
* Flips to true during a save operation, and back to false once the save operation
* completes.
* @type {boolean}
*/
this.isSaving = false;
this.defaults = config.defaults || {};
// mapping definition for the fields that this object will expose
const mapping = mappingSetup.expandShorthand(config.mapping);
const afterESResp = config.afterESResp || _.noop;
const customInit = config.init || _.noop;
// optional search source which this object configures
this.searchSource = config.searchSource ? new SearchSource() : undefined;
// the id of the document
this.id = config.id || void 0;
// Whether to create a copy when the object is saved. This should eventually go away
// in favor of a better rename/save flow.
this.copyOnSave = false;
const parseSearchSource = (searchSourceJson) => {
if (!this.searchSource) return;
// if we have a searchSource, set its state based on the searchSourceJSON field
let state;
try {
state = JSON.parse(searchSourceJson);
} catch (e) {
state = {};
}
const oldState = this.searchSource.toJSON();
const fnProps = _.transform(oldState, function (dynamic, val, name) {
if (_.isFunction(val)) dynamic[name] = val;
}, {});
this.searchSource.set(_.defaults(state, fnProps));
if (!_.isUndefined(this.searchSource.getOwn('query'))) {
this.searchSource.set('query', migrateLegacyQuery(this.searchSource.getOwn('query')));
}
};
/**
* After creation or fetching from ES, ensure that the searchSources index indexPattern
* is an bonafide IndexPattern object.
*
* @return {Promise<IndexPattern | null>}
*/
this.hydrateIndexPattern = (id) => {
if (!this.searchSource) {
return Promise.resolve(null);
}
if (config.clearSavedIndexPattern) {
this.searchSource.set('index', undefined);
return Promise.resolve(null);
}
let index = id || config.indexPattern || this.searchSource.getOwn('index');
if (!index) {
return Promise.resolve(null);
}
// If index is not an IndexPattern object at this point, then it's a string id of an index.
if (!(index instanceof indexPatterns.IndexPattern)) {
index = indexPatterns.get(index);
}
// At this point index will either be an IndexPattern, if cached, or a promise that
// will return an IndexPattern, if not cached.
return Promise.resolve(index).then(indexPattern => {
this.searchSource.set('index', indexPattern);
});
};
/**
* Asynchronously initialize this object - will only run
* once even if called multiple times.
*
* @return {Promise}
* @resolved {SavedObject}
*/
this.init = _.once(() => {
// ensure that the esType is defined
if (!esType) throw new Error('You must define a type name to use SavedObject objects.');
return Promise.resolve()
.then(() => {
// If there is not id, then there is no document to fetch from elasticsearch
if (!this.id) {
// just assign the defaults and be done
_.assign(this, this.defaults);
return this.hydrateIndexPattern().then(() => {
return afterESResp.call(this);
});
}
// fetch the object from ES
return savedObjectsClient.get(esType, this.id)
.then(resp => {
// temporary compatability for savedObjectsClient
return {
_id: resp.id,
_type: resp.type,
_source: _.cloneDeep(resp.attributes),
found: resp._version ? true : false
};
})
.then(this.applyESResp)
.catch(this.applyEsResp);
})
.then(() => customInit.call(this))
.then(() => this);
});
this.applyESResp = (resp) => {
this._source = _.cloneDeep(resp._source);
if (resp.found != null && !resp.found) throw new SavedObjectNotFound(esType, this.id);
const meta = resp._source.kibanaSavedObjectMeta || {};
delete resp._source.kibanaSavedObjectMeta;
if (!config.indexPattern && this._source.indexPattern) {
config.indexPattern = this._source.indexPattern;
delete this._source.indexPattern;
}
// assign the defaults to the response
_.defaults(this._source, this.defaults);
// transform the source using _deserializers
_.forOwn(mapping, (fieldMapping, fieldName) => {
if (fieldMapping._deserialize) {
this._source[fieldName] = fieldMapping._deserialize(this._source[fieldName], resp, fieldName, fieldMapping);
}
});
// Give obj all of the values in _source.fields
_.assign(this, this._source);
this.lastSavedTitle = this.title;
return Promise.try(() => {
parseSearchSource(meta.searchSourceJSON);
return this.hydrateIndexPattern();
}).then(() => {
return Promise.cast(afterESResp.call(this, resp));
});
};
/**
* Serialize this object
*
* @return {Object}
*/
this.serialize = () => {
const body = {};
_.forOwn(mapping, (fieldMapping, fieldName) => {
if (this[fieldName] != null) {
body[fieldName] = (fieldMapping._serialize)
? fieldMapping._serialize(this[fieldName])
: this[fieldName];
}
});
if (this.searchSource) {
body.kibanaSavedObjectMeta = {
searchSourceJSON: angular.toJson(_.omit(this.searchSource.toJSON(), ['sort', 'size']))
};
}
return body;
};
/**
* Returns true if the object's original title has been changed. New objects return false.
* @return {boolean}
*/
this.isTitleChanged = () => {
return this._source && this._source.title !== this.title;
};
/**
* Attempts to create the current object using the serialized source. If an object already
* exists, a warning message requests an overwrite confirmation.
* @param source - serialized version of this object (return value from this.serialize())
* What will be indexed into elasticsearch.
* @returns {Promise} - A promise that is resolved with the objects id if the object is
* successfully indexed. If the overwrite confirmation was rejected, an error is thrown with
* a confirmRejected = true parameter so that case can be handled differently than
* a create or index error.
* @resolved {SavedObject}
*/
const createSource = (source) => {
return savedObjectsClient.create(esType, source, { id: this.id })
.catch(err => {
// record exists, confirm overwriting
if (_.get(err, 'statusCode') === 409) {
const confirmMessage = `Are you sure you want to overwrite ${this.title}?`;
return confirmModalPromise(confirmMessage, { confirmButtonText: `Overwrite ${this.getDisplayName()}` })
.then(() => savedObjectsClient.create(esType, source, { id: this.id, overwrite: true }))
.catch(() => Promise.reject(new Error(OVERWRITE_REJECTED)));
}
return Promise.reject(err);
});
};
/**
* Returns a promise that resolves to true if either the title is unique, or if the user confirmed they
* wished to save the duplicate title. Promise is rejected if the user rejects the confirmation.
*/
const warnIfDuplicateTitle = () => {
// Don't warn if the user isn't updating the title, otherwise that would become very annoying to have
// to confirm the save every time, except when copyOnSave is true, then we do want to check.
if (this.title === this.lastSavedTitle && !this.copyOnSave) {
return Promise.resolve();
}
return findObjectByTitle(savedObjectsClient, this.getEsType(), this.title)
.then(duplicate => {
if (!duplicate) return true;
if (duplicate.id === this.id) return true;
const confirmMessage =
`A ${this.getDisplayName()} with the title '${this.title}' already exists. Would you like to save anyway?`;
return confirmModalPromise(confirmMessage, { confirmButtonText: `Save ${this.getDisplayName()}` })
.catch(() => Promise.reject(new Error(SAVE_DUPLICATE_REJECTED)));
});
};
/**
* Saves this object.
*
* @param {object} [options={}]
* @property {boolean} [options.confirmOverwrite=false] - If true, attempts to create the source so it
* can confirm an overwrite if a document with the id already exists.
* @return {Promise}
* @resolved {String} - The id of the doc
*/
this.save = ({ confirmOverwrite } = {}) => {
// Save the original id in case the save fails.
const originalId = this.id;
// Read https://github.com/elastic/kibana/issues/9056 and
// https://github.com/elastic/kibana/issues/9012 for some background into why this copyOnSave variable
// exists.
// The goal is to move towards a better rename flow, but since our users have been conditioned
// to expect a 'save as' flow during a rename, we are keeping the logic the same until a better
// UI/UX can be worked out.
if (this.copyOnSave) {
this.id = null;
}
const source = this.serialize();
this.isSaving = true;
return warnIfDuplicateTitle()
.then(() => {
if (confirmOverwrite) {
return createSource(source);
} else {
return savedObjectsClient.create(esType, source, { id: this.id, overwrite: true });
}
})
.then((resp) => {
this.id = resp.id;
})
.then(() => {
this.isSaving = false;
this.lastSavedTitle = this.title;
return this.id;
})
.catch((err) => {
this.isSaving = false;
this.id = originalId;
if (isErrorNonFatal(err)) {
return;
}
return Promise.reject(err);
});
};
this.destroy = () => {
if (this.searchSource) {
this.searchSource.cancelQueued();
}
};
/**
* Delete this object from Elasticsearch
* @return {promise}
*/
this.delete = () => {
return savedObjectsClient.delete(esType, this.id);
};
}
return SavedObject;
}