Introducing a SavedObjectRepository (#19013)

* Create a separate SavedObjectRepository that the SavedObjectClient uses

* Moving the repository into lib/

* Fixing test after moving the repository

* Revising tests based on peer review

* Removing awaits

* Adding warning comments regarding the repository's impact on the SOC
This commit is contained in:
Brandon Kobel 2018-05-22 14:56:30 -04:00 committed by GitHub
parent c2e91e6d7b
commit cc707eabe2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 1194 additions and 964 deletions

View file

@ -1,7 +1,4 @@
export { getSearchDsl } from './search_dsl';
export { trimIdPrefix } from './trim_id_prefix';
export { includedFields } from './included_fields';
export { decorateEsError } from './decorate_es_error';
export { SavedObjectsRepository } from './repository';
import * as errors from './errors';
export { errors };

View file

@ -0,0 +1,416 @@
import uuid from 'uuid';
import { getRootType } from '../../../mappings';
import { getSearchDsl } from './search_dsl';
import { trimIdPrefix } from './trim_id_prefix';
import { includedFields } from './included_fields';
import { decorateEsError } from './decorate_es_error';
import * as errors from './errors';
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
export class SavedObjectsRepository {
constructor(options) {
const {
index,
mappings,
callCluster,
onBeforeWrite = () => {},
} = options;
this._index = index;
this._mappings = mappings;
this._type = getRootType(this._mappings);
this._onBeforeWrite = onBeforeWrite;
this._unwrappedCallCluster = callCluster;
}
/**
* Persists an object
*
* @param {string} type
* @param {object} attributes
* @param {object} [options={}]
* @property {string} [options.id] - force id on creation, not recommended
* @property {boolean} [options.overwrite=false]
* @returns {promise} - { id, type, version, attributes }
*/
async create(type, attributes = {}, options = {}) {
const {
id,
overwrite = false
} = options;
const method = id && !overwrite ? 'create' : 'index';
const time = this._getCurrentTime();
try {
const response = await this._writeToCluster(method, {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
refresh: 'wait_for',
body: {
type,
updated_at: time,
[type]: attributes
},
});
return {
id: trimIdPrefix(response._id, type),
type,
updated_at: time,
version: response._version,
attributes
};
} catch (error) {
if (errors.isNotFoundError(error)) {
// See "503s from missing index" above
throw errors.createEsAutoCreateIndexError();
}
throw error;
}
}
/**
* Creates multiple documents at once
*
* @param {array} objects - [{ type, id, attributes }]
* @param {object} [options={}]
* @property {boolean} [options.overwrite=false] - overwrites existing documents
* @returns {promise} - [{ id, type, version, attributes, error: { message } }]
*/
async bulkCreate(objects, options = {}) {
const {
overwrite = false
} = options;
const time = this._getCurrentTime();
const objectToBulkRequest = (object) => {
const method = object.id && !overwrite ? 'create' : 'index';
return [
{
[method]: {
_id: this._generateEsId(object.type, object.id),
_type: this._type,
}
},
{
type: object.type,
updated_at: time,
[object.type]: object.attributes
}
];
};
const { items } = await this._writeToCluster('bulk', {
index: this._index,
refresh: 'wait_for',
body: objects.reduce((acc, object) => ([
...acc,
...objectToBulkRequest(object)
]), []),
});
return items.map((response, i) => {
const {
error,
_id: responseId,
_version: version,
} = Object.values(response)[0];
const {
id = responseId,
type,
attributes,
} = objects[i];
if (error) {
return {
id,
type,
error: {
message: error.reason || JSON.stringify(error)
}
};
}
return {
id,
type,
updated_at: time,
version,
attributes
};
});
}
/**
* Deletes an object
*
* @param {string} type
* @param {string} id
* @returns {promise}
*/
async delete(type, id) {
const response = await this._writeToCluster('delete', {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
refresh: 'wait_for',
ignore: [404],
});
const deleted = response.result === 'deleted';
if (deleted) {
return {};
}
const docNotFound = response.result === 'not_found';
const indexNotFound = response.error && response.error.type === 'index_not_found_exception';
if (docNotFound || indexNotFound) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
throw new Error(
`Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, response, })}`
);
}
/**
* @param {object} [options={}]
* @property {string} [options.type]
* @property {string} [options.search]
* @property {Array<string>} [options.searchFields] - see Elasticsearch Simple Query String
* Query field argument for more information
* @property {integer} [options.page=1]
* @property {integer} [options.perPage=20]
* @property {string} [options.sortField]
* @property {string} [options.sortOrder]
* @property {Array<string>} [options.fields]
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
async find(options = {}) {
const {
type,
search,
searchFields,
page = 1,
perPage = 20,
sortField,
sortOrder,
fields,
includeTypes,
} = options;
if (searchFields && !Array.isArray(searchFields)) {
throw new TypeError('options.searchFields must be an array');
}
if (fields && !Array.isArray(fields)) {
throw new TypeError('options.searchFields must be an array');
}
const esOptions = {
index: this._index,
size: perPage,
from: perPage * (page - 1),
_source: includedFields(type, fields),
ignore: [404],
body: {
version: true,
...getSearchDsl(this._mappings, {
search,
searchFields,
type,
includeTypes,
sortField,
sortOrder
})
}
};
const response = await this._callCluster('search', esOptions);
if (response.status === 404) {
// 404 is only possible here if the index is missing, which
// we don't want to leak, see "404s from missing index" above
return {
page,
per_page: perPage,
total: 0,
saved_objects: []
};
}
return {
page,
per_page: perPage,
total: response.hits.total,
saved_objects: response.hits.hits.map(hit => {
const { type, updated_at: updatedAt } = hit._source;
return {
id: trimIdPrefix(hit._id, type),
type,
...updatedAt && { updated_at: updatedAt },
version: hit._version,
attributes: hit._source[type],
};
}),
};
}
/**
* Returns an array of objects by id
*
* @param {array} objects - an array ids, or an array of objects containing id and optionally type
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }] }
* @example
*
* bulkGet([
* { id: 'one', type: 'config' },
* { id: 'foo', type: 'index-pattern' }
* ])
*/
async bulkGet(objects = []) {
if (objects.length === 0) {
return { saved_objects: [] };
}
const response = await this._callCluster('mget', {
index: this._index,
body: {
docs: objects.map(object => ({
_id: this._generateEsId(object.type, object.id),
_type: this._type,
}))
}
});
return {
saved_objects: response.docs.map((doc, i) => {
const { id, type } = objects[i];
if (!doc.found) {
return {
id,
type,
error: { statusCode: 404, message: 'Not found' }
};
}
const time = doc._source.updated_at;
return {
id,
type,
...time && { updated_at: time },
version: doc._version,
attributes: doc._source[type]
};
})
};
}
/**
* Gets a single object
*
* @param {string} type
* @param {string} id
* @returns {promise} - { id, type, version, attributes }
*/
async get(type, id) {
const response = await this._callCluster('get', {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
ignore: [404]
});
const docNotFound = response.found === false;
const indexNotFound = response.status === 404;
if (docNotFound || indexNotFound) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
const { updated_at: updatedAt } = response._source;
return {
id,
type,
...updatedAt && { updated_at: updatedAt },
version: response._version,
attributes: response._source[type]
};
}
/**
* Updates an object
*
* @param {string} type
* @param {string} id
* @param {object} [options={}]
* @property {integer} options.version - ensures version matches that of persisted object
* @returns {promise}
*/
async update(type, id, attributes, options = {}) {
const time = this._getCurrentTime();
const response = await this._writeToCluster('update', {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
version: options.version,
refresh: 'wait_for',
ignore: [404],
body: {
doc: {
updated_at: time,
[type]: attributes
}
},
});
if (response.status === 404) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
return {
id,
type,
updated_at: time,
version: response._version,
attributes
};
}
async _writeToCluster(method, params) {
try {
await this._onBeforeWrite();
return await this._callCluster(method, params);
} catch (err) {
throw decorateEsError(err);
}
}
async _callCluster(method, params) {
try {
return await this._unwrappedCallCluster(method, params);
} catch (err) {
throw decorateEsError(err);
}
}
_generateEsId(type, id) {
return `${type}:${id || uuid.v1()}`;
}
_getCurrentTime() {
return new Date().toISOString();
}
}

View file

@ -0,0 +1,647 @@
import sinon from 'sinon';
import { delay } from 'bluebird';
import { SavedObjectsRepository } from './repository';
import * as getSearchDslNS from './search_dsl/search_dsl';
import { getSearchDsl } from './search_dsl';
import * as errors from './errors';
import elasticsearch from 'elasticsearch';
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
describe('SavedObjectsRepository', () => {
const sandbox = sinon.createSandbox();
let callAdminCluster;
let onBeforeWrite;
let savedObjectsRepository;
const mockTimestamp = '2017-08-14T15:49:14.886Z';
const mockTimestampFields = { updated_at: mockTimestamp };
const searchResults = {
hits: {
total: 3,
hits: [{
_index: '.kibana',
_type: 'doc',
_id: 'index-pattern:logstash-*',
_score: 1,
_source: {
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': {
title: 'logstash-*',
timeFieldName: '@timestamp',
notExpandable: true
}
}
}, {
_index: '.kibana',
_type: 'doc',
_id: 'config:6.0.0-alpha1',
_score: 1,
_source: {
type: 'config',
...mockTimestampFields,
config: {
buildNum: 8467,
defaultIndex: 'logstash-*'
}
}
}, {
_index: '.kibana',
_type: 'doc',
_id: 'index-pattern:stocks-*',
_score: 1,
_source: {
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': {
title: 'stocks-*',
timeFieldName: '@timestamp',
notExpandable: true
}
}
}]
}
};
const mappings = {
doc: {
properties: {
'index-pattern': {
properties: {
someField: {
type: 'keyword'
}
}
}
}
}
};
beforeEach(() => {
callAdminCluster = sandbox.stub();
onBeforeWrite = sandbox.stub();
savedObjectsRepository = new SavedObjectsRepository({
index: '.kibana-test',
mappings,
callCluster: callAdminCluster,
onBeforeWrite
});
sandbox.stub(savedObjectsRepository, '_getCurrentTime').returns(mockTimestamp);
sandbox.stub(getSearchDslNS, 'getSearchDsl').returns({});
});
afterEach(() => {
sandbox.restore();
});
describe('#create', () => {
beforeEach(() => {
callAdminCluster.returns(Promise.resolve({
_type: 'doc',
_id: 'index-pattern:logstash-*',
_version: 2
}));
});
it('formats Elasticsearch response', async () => {
const response = await savedObjectsRepository.create('index-pattern', {
title: 'Logstash'
});
expect(response).toEqual({
type: 'index-pattern',
id: 'logstash-*',
...mockTimestampFields,
version: 2,
attributes: {
title: 'Logstash',
}
});
});
it('should use ES index action', async () => {
await savedObjectsRepository.create('index-pattern', {
id: 'logstash-*',
title: 'Logstash'
});
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWith(callAdminCluster, 'index');
sinon.assert.calledOnce(onBeforeWrite);
});
it('should use create action if ID defined and overwrite=false', async () => {
await savedObjectsRepository.create('index-pattern', {
title: 'Logstash'
}, {
id: 'logstash-*',
});
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWith(callAdminCluster, 'create');
sinon.assert.calledOnce(onBeforeWrite);
});
it('allows for id to be provided', async () => {
await savedObjectsRepository.create('index-pattern', {
title: 'Logstash'
}, { id: 'logstash-*' });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: 'index-pattern:logstash-*'
}));
sinon.assert.calledOnce(onBeforeWrite);
});
it('self-generates an ID', async () => {
await savedObjectsRepository.create('index-pattern', {
title: 'Logstash'
});
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: sinon.match(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/)
}));
sinon.assert.calledOnce(onBeforeWrite);
});
});
describe('#bulkCreate', () => {
it('formats Elasticsearch request', async () => {
callAdminCluster.returns({ items: [] });
await savedObjectsRepository.bulkCreate([
{ type: 'config', id: 'one', attributes: { title: 'Test One' } },
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }
]);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
{ create: { _type: 'doc', _id: 'config:one' } },
{ type: 'config', ...mockTimestampFields, config: { title: 'Test One' } },
{ create: { _type: 'doc', _id: 'index-pattern:two' } },
{ type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } }
]
}));
sinon.assert.calledOnce(onBeforeWrite);
});
it('should overwrite objects if overwrite is truthy', async () => {
callAdminCluster.returns({ items: [] });
await savedObjectsRepository.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { overwrite: false });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
// uses create because overwriting is not allowed
{ create: { _type: 'doc', _id: 'foo:bar' } },
{ type: 'foo', ...mockTimestampFields, 'foo': {} },
]
}));
sinon.assert.calledOnce(onBeforeWrite);
callAdminCluster.resetHistory();
onBeforeWrite.resetHistory();
await savedObjectsRepository.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { overwrite: true });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
// uses index because overwriting is allowed
{ index: { _type: 'doc', _id: 'foo:bar' } },
{ type: 'foo', ...mockTimestampFields, 'foo': {} },
]
}));
sinon.assert.calledOnce(onBeforeWrite);
});
it('returns document errors', async () => {
callAdminCluster.returns(Promise.resolve({
errors: false,
items: [{
create: {
_type: 'doc',
_id: 'config:one',
error: {
reason: 'type[config] missing'
}
}
}, {
create: {
_type: 'doc',
_id: 'index-pattern:two',
_version: 2
}
}]
}));
const response = await savedObjectsRepository.bulkCreate([
{ type: 'config', id: 'one', attributes: { title: 'Test One' } },
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }
]);
expect(response).toEqual([
{
id: 'one',
type: 'config',
error: { message: 'type[config] missing' }
}, {
id: 'two',
type: 'index-pattern',
version: 2,
...mockTimestampFields,
attributes: { title: 'Test Two' },
}
]);
});
it('formats Elasticsearch response', async () => {
callAdminCluster.returns(Promise.resolve({
errors: false,
items: [{
create: {
_type: 'doc',
_id: 'config:one',
_version: 2
}
}, {
create: {
_type: 'doc',
_id: 'index-pattern:two',
_version: 2
}
}]
}));
const response = await savedObjectsRepository.bulkCreate([
{ type: 'config', id: 'one', attributes: { title: 'Test One' } },
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }
]);
expect(response).toEqual([
{
id: 'one',
type: 'config',
version: 2,
...mockTimestampFields,
attributes: { title: 'Test One' },
}, {
id: 'two',
type: 'index-pattern',
version: 2,
...mockTimestampFields,
attributes: { title: 'Test Two' },
}
]);
});
});
describe('#delete', () => {
it('throws notFound when ES is unable to find the document', async () => {
expect.assertions(1);
callAdminCluster.returns(Promise.resolve({
result: 'not_found'
}));
try {
await savedObjectsRepository.delete('index-pattern', 'logstash-*');
} catch(e) {
expect(e.output.statusCode).toEqual(404);
}
});
it('passes the parameters to callAdminCluster', async () => {
callAdminCluster.returns({
result: 'deleted'
});
await savedObjectsRepository.delete('index-pattern', 'logstash-*');
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'delete', {
type: 'doc',
id: 'index-pattern:logstash-*',
refresh: 'wait_for',
index: '.kibana-test',
ignore: [404],
});
sinon.assert.calledOnce(onBeforeWrite);
});
});
describe('#find', () => {
beforeEach(() => {
callAdminCluster.returns(searchResults);
});
it('requires searchFields be an array if defined', async () => {
try {
await savedObjectsRepository.find({ searchFields: 'string' });
throw new Error('expected find() to reject');
} catch (error) {
sinon.assert.notCalled(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
expect(error.message).toMatch('must be an array');
}
});
it('requires fields be an array if defined', async () => {
try {
await savedObjectsRepository.find({ fields: 'string' });
throw new Error('expected find() to reject');
} catch (error) {
sinon.assert.notCalled(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
expect(error.message).toMatch('must be an array');
}
});
it('passes mappings, search, searchFields, type, sortField, and sortOrder to getSearchDsl', async () => {
const relevantOpts = {
search: 'foo*',
searchFields: ['foo'],
type: 'bar',
sortField: 'name',
sortOrder: 'desc',
includeTypes: ['index-pattern', 'dashboard'],
};
await savedObjectsRepository.find(relevantOpts);
sinon.assert.calledOnce(getSearchDsl);
sinon.assert.calledWithExactly(getSearchDsl, mappings, relevantOpts);
});
it('merges output of getSearchDsl into es request body', async () => {
getSearchDsl.returns({ query: 1, aggregations: 2 });
await savedObjectsRepository.find();
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledWithExactly(callAdminCluster, 'search', sinon.match({
body: sinon.match({
query: 1,
aggregations: 2,
})
}));
});
it('formats Elasticsearch response', async () => {
const count = searchResults.hits.hits.length;
const response = await savedObjectsRepository.find();
expect(response.total).toBe(count);
expect(response.saved_objects).toHaveLength(count);
searchResults.hits.hits.forEach((doc, i) => {
expect(response.saved_objects[i]).toEqual({
id: doc._id.replace(/(index-pattern|config)\:/, ''),
type: doc._source.type,
...mockTimestampFields,
version: doc._version,
attributes: doc._source[doc._source.type]
});
});
});
it('accepts per_page/page', async () => {
await savedObjectsRepository.find({ perPage: 10, page: 6 });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
size: 10,
from: 50
}));
sinon.assert.notCalled(onBeforeWrite);
});
it('can filter by fields', async () => {
await savedObjectsRepository.find({ fields: ['title'] });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
_source: [
'*.title', 'type', 'title'
]
}));
sinon.assert.notCalled(onBeforeWrite);
});
});
describe('#get', () => {
beforeEach(() => {
callAdminCluster.returns(Promise.resolve({
_id: 'index-pattern:logstash-*',
_type: 'doc',
_version: 2,
_source: {
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': {
title: 'Testing'
}
}
}));
});
it('formats Elasticsearch response', async () => {
const response = await savedObjectsRepository.get('index-pattern', 'logstash-*');
sinon.assert.notCalled(onBeforeWrite);
expect(response).toEqual({
id: 'logstash-*',
type: 'index-pattern',
updated_at: mockTimestamp,
version: 2,
attributes: {
title: 'Testing'
}
});
});
it('prepends type to the id', async () => {
await savedObjectsRepository.get('index-pattern', 'logstash-*');
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: 'index-pattern:logstash-*',
type: 'doc'
}));
});
});
describe('#bulkGet', () => {
it('accepts a array of mixed type and ids', async () => {
callAdminCluster.returns({ docs: [] });
await savedObjectsRepository.bulkGet([
{ id: 'one', type: 'config' },
{ id: 'two', type: 'index-pattern' }
]);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
body: {
docs: [
{ _type: 'doc', _id: 'config:one' },
{ _type: 'doc', _id: 'index-pattern:two' }
]
}
}));
sinon.assert.notCalled(onBeforeWrite);
});
it('returns early for empty objects argument', async () => {
callAdminCluster.returns({ docs: [] });
const response = await savedObjectsRepository.bulkGet([]);
expect(response.saved_objects).toHaveLength(0);
sinon.assert.notCalled(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
});
it('reports error on missed objects', async () => {
callAdminCluster.returns(Promise.resolve({
docs: [{
_type: 'doc',
_id: 'config:good',
found: true,
_version: 2,
_source: { ...mockTimestampFields, config: { title: 'Test' } }
}, {
_type: 'doc',
_id: 'config:bad',
found: false
}]
}));
const { saved_objects: savedObjects } = await savedObjectsRepository.bulkGet(
[{ id: 'good', type: 'config' }, { id: 'bad', type: 'config' }]
);
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
expect(savedObjects).toHaveLength(2);
expect(savedObjects[0]).toEqual({
id: 'good',
type: 'config',
...mockTimestampFields,
version: 2,
attributes: { title: 'Test' }
});
expect(savedObjects[1]).toEqual({
id: 'bad',
type: 'config',
error: { statusCode: 404, message: 'Not found' }
});
});
});
describe('#update', () => {
const id = 'logstash-*';
const type = 'index-pattern';
const newVersion = 2;
const attributes = { title: 'Testing' };
beforeEach(() => {
callAdminCluster.returns(Promise.resolve({
_id: `${type}:${id}`,
_type: 'doc',
_version: newVersion,
result: 'updated'
}));
});
it('returns current ES document version', async () => {
const response = await savedObjectsRepository.update('index-pattern', 'logstash-*', attributes);
expect(response).toEqual({
id,
type,
...mockTimestampFields,
version: newVersion,
attributes
});
});
it('accepts version', async () => {
await savedObjectsRepository.update(
type,
id,
{ title: 'Testing' },
{ version: newVersion - 1 }
);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
version: newVersion - 1
}));
});
it('passes the parameters to callAdminCluster', async () => {
await savedObjectsRepository.update('index-pattern', 'logstash-*', { title: 'Testing' });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'update', {
type: 'doc',
id: 'index-pattern:logstash-*',
version: undefined,
body: {
doc: { updated_at: mockTimestamp, 'index-pattern': { title: 'Testing' } }
},
ignore: [404],
refresh: 'wait_for',
index: '.kibana-test'
});
sinon.assert.calledOnce(onBeforeWrite);
});
});
describe('onBeforeWrite', () => {
it('blocks calls to callCluster of requests', async () => {
onBeforeWrite.returns(delay(500));
callAdminCluster.returns({ result: 'deleted', found: true });
const deletePromise = savedObjectsRepository.delete('type', 'id');
await delay(100);
sinon.assert.calledOnce(onBeforeWrite);
sinon.assert.notCalled(callAdminCluster);
await deletePromise;
sinon.assert.calledOnce(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
});
it('can throw es errors and have them decorated as SavedObjectsClient errors', async () => {
expect.assertions(3);
const es401 = new elasticsearch.errors[401];
expect(errors.isNotAuthorizedError(es401)).toBe(false);
onBeforeWrite.throws(es401);
try {
await savedObjectsRepository.delete('type', 'id');
} catch (error) {
sinon.assert.calledOnce(onBeforeWrite);
expect(error).toBe(es401);
expect(errors.isNotAuthorizedError(error)).toBe(true);
}
});
});
});

View file

@ -1,29 +1,11 @@
import uuid from 'uuid';
import { getRootType } from '../../mappings';
import {
getSearchDsl,
trimIdPrefix,
includedFields,
decorateEsError,
SavedObjectsRepository,
errors,
} from './lib';
export class SavedObjectsClient {
constructor(options) {
const {
index,
mappings,
callCluster,
onBeforeWrite = () => {},
} = options;
this._index = index;
this._mappings = mappings;
this._type = getRootType(this._mappings);
this._onBeforeWrite = onBeforeWrite;
this._unwrappedCallCluster = callCluster;
this._repository = new SavedObjectsRepository(options);
}
/**
@ -105,42 +87,7 @@ export class SavedObjectsClient {
* @returns {promise} - { id, type, version, attributes }
*/
async create(type, attributes = {}, options = {}) {
const {
id,
overwrite = false
} = options;
const method = id && !overwrite ? 'create' : 'index';
const time = this._getCurrentTime();
try {
const response = await this._writeToCluster(method, {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
refresh: 'wait_for',
body: {
type,
updated_at: time,
[type]: attributes
},
});
return {
id: trimIdPrefix(response._id, type),
type,
updated_at: time,
version: response._version,
attributes
};
} catch (error) {
if (errors.isNotFoundError(error)) {
// See "503s from missing index" above
throw errors.createEsAutoCreateIndexError();
}
throw error;
}
return this._repository.create(type, attributes, options);
}
/**
@ -152,68 +99,7 @@ export class SavedObjectsClient {
* @returns {promise} - [{ id, type, version, attributes, error: { message } }]
*/
async bulkCreate(objects, options = {}) {
const {
overwrite = false
} = options;
const time = this._getCurrentTime();
const objectToBulkRequest = (object) => {
const method = object.id && !overwrite ? 'create' : 'index';
return [
{
[method]: {
_id: this._generateEsId(object.type, object.id),
_type: this._type,
}
},
{
type: object.type,
updated_at: time,
[object.type]: object.attributes
}
];
};
const { items } = await this._writeToCluster('bulk', {
index: this._index,
refresh: 'wait_for',
body: objects.reduce((acc, object) => ([
...acc,
...objectToBulkRequest(object)
]), []),
});
return items.map((response, i) => {
const {
error,
_id: responseId,
_version: version,
} = Object.values(response)[0];
const {
id = responseId,
type,
attributes,
} = objects[i];
if (error) {
return {
id,
type,
error: {
message: error.reason || JSON.stringify(error)
}
};
}
return {
id,
type,
updated_at: time,
version,
attributes
};
});
return this._repository.bulkCreate(objects, options);
}
/**
@ -224,29 +110,7 @@ export class SavedObjectsClient {
* @returns {promise}
*/
async delete(type, id) {
const response = await this._writeToCluster('delete', {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
refresh: 'wait_for',
ignore: [404],
});
const deleted = response.result === 'deleted';
if (deleted) {
return {};
}
const docNotFound = response.result === 'not_found';
const indexNotFound = response.error && response.error.type === 'index_not_found_exception';
if (docNotFound || indexNotFound) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
throw new Error(
`Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, response, })}`
);
return this._repository.delete(type, id);
}
/**
@ -263,73 +127,7 @@ export class SavedObjectsClient {
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
async find(options = {}) {
const {
type,
search,
searchFields,
page = 1,
perPage = 20,
sortField,
sortOrder,
fields,
includeTypes,
} = options;
if (searchFields && !Array.isArray(searchFields)) {
throw new TypeError('options.searchFields must be an array');
}
if (fields && !Array.isArray(fields)) {
throw new TypeError('options.searchFields must be an array');
}
const esOptions = {
index: this._index,
size: perPage,
from: perPage * (page - 1),
_source: includedFields(type, fields),
ignore: [404],
body: {
version: true,
...getSearchDsl(this._mappings, {
search,
searchFields,
type,
includeTypes,
sortField,
sortOrder
})
}
};
const response = await this._callCluster('search', esOptions);
if (response.status === 404) {
// 404 is only possible here if the index is missing, which
// we don't want to leak, see "404s from missing index" above
return {
page,
per_page: perPage,
total: 0,
saved_objects: []
};
}
return {
page,
per_page: perPage,
total: response.hits.total,
saved_objects: response.hits.hits.map(hit => {
const { type, updated_at: updatedAt } = hit._source;
return {
id: trimIdPrefix(hit._id, type),
type,
...updatedAt && { updated_at: updatedAt },
version: hit._version,
attributes: hit._source[type],
};
}),
};
return this._repository.find(options);
}
/**
@ -345,42 +143,7 @@ export class SavedObjectsClient {
* ])
*/
async bulkGet(objects = []) {
if (objects.length === 0) {
return { saved_objects: [] };
}
const response = await this._callCluster('mget', {
index: this._index,
body: {
docs: objects.map(object => ({
_id: this._generateEsId(object.type, object.id),
_type: this._type,
}))
}
});
return {
saved_objects: response.docs.map((doc, i) => {
const { id, type } = objects[i];
if (!doc.found) {
return {
id,
type,
error: { statusCode: 404, message: 'Not found' }
};
}
const time = doc._source.updated_at;
return {
id,
type,
...time && { updated_at: time },
version: doc._version,
attributes: doc._source[type]
};
})
};
return this._repository.bulkGet(objects);
}
/**
@ -391,29 +154,7 @@ export class SavedObjectsClient {
* @returns {promise} - { id, type, version, attributes }
*/
async get(type, id) {
const response = await this._callCluster('get', {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
ignore: [404]
});
const docNotFound = response.found === false;
const indexNotFound = response.status === 404;
if (docNotFound || indexNotFound) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
const { updated_at: updatedAt } = response._source;
return {
id,
type,
...updatedAt && { updated_at: updatedAt },
version: response._version,
attributes: response._source[type]
};
return this._repository.get(type, id);
}
/**
@ -426,58 +167,6 @@ export class SavedObjectsClient {
* @returns {promise}
*/
async update(type, id, attributes, options = {}) {
const time = this._getCurrentTime();
const response = await this._writeToCluster('update', {
id: this._generateEsId(type, id),
type: this._type,
index: this._index,
version: options.version,
refresh: 'wait_for',
ignore: [404],
body: {
doc: {
updated_at: time,
[type]: attributes
}
},
});
if (response.status === 404) {
// see "404s from missing index" above
throw errors.createGenericNotFoundError();
}
return {
id,
type,
updated_at: time,
version: response._version,
attributes
};
}
async _writeToCluster(method, params) {
try {
await this._onBeforeWrite();
return await this._callCluster(method, params);
} catch (err) {
throw decorateEsError(err);
}
}
async _callCluster(method, params) {
try {
return await this._unwrappedCallCluster(method, params);
} catch (err) {
throw decorateEsError(err);
}
}
_generateEsId(type, id) {
return `${type}:${id || uuid.v1()}`;
}
_getCurrentTime() {
return new Date().toISOString();
return this._repository.update(type, id, attributes, options);
}
}

View file

@ -1,643 +1,124 @@
import sinon from 'sinon';
import { delay } from 'bluebird';
import { SavedObjectsClient } from './saved_objects_client';
import * as getSearchDslNS from './lib/search_dsl/search_dsl';
import { getSearchDsl } from './lib';
import elasticsearch from 'elasticsearch';
import { SavedObjectsRepository } from './lib/repository';
jest.mock('./lib/repository');
describe('SavedObjectsClient', () => {
const sandbox = sinon.createSandbox();
let callAdminCluster;
let onBeforeWrite;
let savedObjectsClient;
const mockTimestamp = '2017-08-14T15:49:14.886Z';
const mockTimestampFields = { updated_at: mockTimestamp };
const searchResults = {
hits: {
total: 3,
hits: [{
_index: '.kibana',
_type: 'doc',
_id: 'index-pattern:logstash-*',
_score: 1,
_source: {
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': {
title: 'logstash-*',
timeFieldName: '@timestamp',
notExpandable: true
}
}
}, {
_index: '.kibana',
_type: 'doc',
_id: 'config:6.0.0-alpha1',
_score: 1,
_source: {
type: 'config',
...mockTimestampFields,
config: {
buildNum: 8467,
defaultIndex: 'logstash-*'
}
}
}, {
_index: '.kibana',
_type: 'doc',
_id: 'index-pattern:stocks-*',
_score: 1,
_source: {
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': {
title: 'stocks-*',
timeFieldName: '@timestamp',
notExpandable: true
}
}
}]
}
};
const mappings = {
doc: {
properties: {
'index-pattern': {
properties: {
someField: {
type: 'keyword'
}
}
}
}
}
};
beforeEach(() => {
callAdminCluster = sandbox.stub();
onBeforeWrite = sandbox.stub();
savedObjectsClient = new SavedObjectsClient({
index: '.kibana-test',
mappings,
callCluster: callAdminCluster,
onBeforeWrite
});
sandbox.stub(savedObjectsClient, '_getCurrentTime').returns(mockTimestamp);
sandbox.stub(getSearchDslNS, 'getSearchDsl').returns({});
});
afterEach(() => {
sandbox.restore();
});
describe('#create', () => {
beforeEach(() => {
callAdminCluster.returns(Promise.resolve({
_type: 'doc',
_id: 'index-pattern:logstash-*',
_version: 2
}));
});
it('formats Elasticsearch response', async () => {
const response = await savedObjectsClient.create('index-pattern', {
title: 'Logstash'
});
expect(response).toEqual({
type: 'index-pattern',
id: 'logstash-*',
...mockTimestampFields,
version: 2,
attributes: {
title: 'Logstash',
}
});
});
it('should use ES index action', async () => {
await savedObjectsClient.create('index-pattern', {
id: 'logstash-*',
title: 'Logstash'
});
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWith(callAdminCluster, 'index');
sinon.assert.calledOnce(onBeforeWrite);
});
it('should use create action if ID defined and overwrite=false', async () => {
await savedObjectsClient.create('index-pattern', {
title: 'Logstash'
}, {
id: 'logstash-*',
});
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWith(callAdminCluster, 'create');
sinon.assert.calledOnce(onBeforeWrite);
});
it('allows for id to be provided', async () => {
await savedObjectsClient.create('index-pattern', {
title: 'Logstash'
}, { id: 'logstash-*' });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: 'index-pattern:logstash-*'
}));
sinon.assert.calledOnce(onBeforeWrite);
});
it('self-generates an ID', async () => {
await savedObjectsClient.create('index-pattern', {
title: 'Logstash'
});
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: sinon.match(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/)
}));
sinon.assert.calledOnce(onBeforeWrite);
});
});
describe('#bulkCreate', () => {
it('formats Elasticsearch request', async () => {
callAdminCluster.returns({ items: [] });
await savedObjectsClient.bulkCreate([
{ type: 'config', id: 'one', attributes: { title: 'Test One' } },
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }
]);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
{ create: { _type: 'doc', _id: 'config:one' } },
{ type: 'config', ...mockTimestampFields, config: { title: 'Test One' } },
{ create: { _type: 'doc', _id: 'index-pattern:two' } },
{ type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } }
]
}));
sinon.assert.calledOnce(onBeforeWrite);
});
it('should overwrite objects if overwrite is truthy', async () => {
callAdminCluster.returns({ items: [] });
await savedObjectsClient.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { overwrite: false });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
// uses create because overwriting is not allowed
{ create: { _type: 'doc', _id: 'foo:bar' } },
{ type: 'foo', ...mockTimestampFields, 'foo': {} },
]
}));
sinon.assert.calledOnce(onBeforeWrite);
callAdminCluster.resetHistory();
onBeforeWrite.resetHistory();
await savedObjectsClient.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { overwrite: true });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
// uses index because overwriting is allowed
{ index: { _type: 'doc', _id: 'foo:bar' } },
{ type: 'foo', ...mockTimestampFields, 'foo': {} },
]
}));
sinon.assert.calledOnce(onBeforeWrite);
});
it('returns document errors', async () => {
callAdminCluster.returns(Promise.resolve({
errors: false,
items: [{
create: {
_type: 'doc',
_id: 'config:one',
error: {
reason: 'type[config] missing'
}
}
}, {
create: {
_type: 'doc',
_id: 'index-pattern:two',
_version: 2
}
}]
}));
const response = await savedObjectsClient.bulkCreate([
{ type: 'config', id: 'one', attributes: { title: 'Test One' } },
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }
]);
expect(response).toEqual([
{
id: 'one',
type: 'config',
error: { message: 'type[config] missing' }
}, {
id: 'two',
type: 'index-pattern',
version: 2,
...mockTimestampFields,
attributes: { title: 'Test Two' },
}
]);
});
it('formats Elasticsearch response', async () => {
callAdminCluster.returns(Promise.resolve({
errors: false,
items: [{
create: {
_type: 'doc',
_id: 'config:one',
_version: 2
}
}, {
create: {
_type: 'doc',
_id: 'index-pattern:two',
_version: 2
}
}]
}));
const response = await savedObjectsClient.bulkCreate([
{ type: 'config', id: 'one', attributes: { title: 'Test One' } },
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }
]);
expect(response).toEqual([
{
id: 'one',
type: 'config',
version: 2,
...mockTimestampFields,
attributes: { title: 'Test One' },
}, {
id: 'two',
type: 'index-pattern',
version: 2,
...mockTimestampFields,
attributes: { title: 'Test Two' },
}
]);
});
});
describe('#delete', () => {
it('throws notFound when ES is unable to find the document', async () => {
expect.assertions(1);
callAdminCluster.returns(Promise.resolve({
result: 'not_found'
}));
try {
await savedObjectsClient.delete('index-pattern', 'logstash-*');
} catch(e) {
expect(e.output.statusCode).toEqual(404);
}
});
it('passes the parameters to callAdminCluster', async () => {
callAdminCluster.returns({
result: 'deleted'
});
await savedObjectsClient.delete('index-pattern', 'logstash-*');
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'delete', {
type: 'doc',
id: 'index-pattern:logstash-*',
refresh: 'wait_for',
index: '.kibana-test',
ignore: [404],
});
sinon.assert.calledOnce(onBeforeWrite);
});
});
describe('#find', () => {
beforeEach(() => {
callAdminCluster.returns(searchResults);
});
it('requires searchFields be an array if defined', async () => {
try {
await savedObjectsClient.find({ searchFields: 'string' });
throw new Error('expected find() to reject');
} catch (error) {
sinon.assert.notCalled(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
expect(error.message).toMatch('must be an array');
}
});
it('requires fields be an array if defined', async () => {
try {
await savedObjectsClient.find({ fields: 'string' });
throw new Error('expected find() to reject');
} catch (error) {
sinon.assert.notCalled(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
expect(error.message).toMatch('must be an array');
}
});
it('passes mappings, search, searchFields, type, sortField, and sortOrder to getSearchDsl', async () => {
const relevantOpts = {
search: 'foo*',
searchFields: ['foo'],
type: 'bar',
sortField: 'name',
sortOrder: 'desc',
includeTypes: ['index-pattern', 'dashboard'],
};
await savedObjectsClient.find(relevantOpts);
sinon.assert.calledOnce(getSearchDsl);
sinon.assert.calledWithExactly(getSearchDsl, mappings, relevantOpts);
});
it('merges output of getSearchDsl into es request body', async () => {
getSearchDsl.returns({ query: 1, aggregations: 2 });
await savedObjectsClient.find();
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledWithExactly(callAdminCluster, 'search', sinon.match({
body: sinon.match({
query: 1,
aggregations: 2,
})
}));
});
it('formats Elasticsearch response', async () => {
const count = searchResults.hits.hits.length;
const response = await savedObjectsClient.find();
expect(response.total).toBe(count);
expect(response.saved_objects).toHaveLength(count);
searchResults.hits.hits.forEach((doc, i) => {
expect(response.saved_objects[i]).toEqual({
id: doc._id.replace(/(index-pattern|config)\:/, ''),
type: doc._source.type,
...mockTimestampFields,
version: doc._version,
attributes: doc._source[doc._source.type]
});
});
});
it('accepts per_page/page', async () => {
await savedObjectsClient.find({ perPage: 10, page: 6 });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
size: 10,
from: 50
}));
sinon.assert.notCalled(onBeforeWrite);
});
it('can filter by fields', async () => {
await savedObjectsClient.find({ fields: ['title'] });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
_source: [
'*.title', 'type', 'title'
]
}));
sinon.assert.notCalled(onBeforeWrite);
});
});
describe('#get', () => {
beforeEach(() => {
callAdminCluster.returns(Promise.resolve({
_id: 'index-pattern:logstash-*',
_type: 'doc',
_version: 2,
_source: {
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': {
title: 'Testing'
}
}
}));
});
it('formats Elasticsearch response', async () => {
const response = await savedObjectsClient.get('index-pattern', 'logstash-*');
sinon.assert.notCalled(onBeforeWrite);
expect(response).toEqual({
id: 'logstash-*',
type: 'index-pattern',
updated_at: mockTimestamp,
version: 2,
attributes: {
title: 'Testing'
}
});
});
it('prepends type to the id', async () => {
await savedObjectsClient.get('index-pattern', 'logstash-*');
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
id: 'index-pattern:logstash-*',
type: 'doc'
}));
});
});
describe('#bulkGet', () => {
it('accepts a array of mixed type and ids', async () => {
callAdminCluster.returns({ docs: [] });
await savedObjectsClient.bulkGet([
{ id: 'one', type: 'config' },
{ id: 'two', type: 'index-pattern' }
]);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
body: {
docs: [
{ _type: 'doc', _id: 'config:one' },
{ _type: 'doc', _id: 'index-pattern:two' }
]
}
}));
sinon.assert.notCalled(onBeforeWrite);
});
it('returns early for empty objects argument', async () => {
callAdminCluster.returns({ docs: [] });
const response = await savedObjectsClient.bulkGet([]);
expect(response.saved_objects).toHaveLength(0);
sinon.assert.notCalled(callAdminCluster);
sinon.assert.notCalled(onBeforeWrite);
});
it('reports error on missed objects', async () => {
callAdminCluster.returns(Promise.resolve({
docs: [{
_type: 'doc',
_id: 'config:good',
found: true,
_version: 2,
_source: { ...mockTimestampFields, config: { title: 'Test' } }
}, {
_type: 'doc',
_id: 'config:bad',
found: false
}]
}));
const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(
[{ id: 'good', type: 'config' }, { id: 'bad', type: 'config' }]
);
sinon.assert.notCalled(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
expect(savedObjects).toHaveLength(2);
expect(savedObjects[0]).toEqual({
id: 'good',
type: 'config',
...mockTimestampFields,
version: 2,
attributes: { title: 'Test' }
});
expect(savedObjects[1]).toEqual({
id: 'bad',
type: 'config',
error: { statusCode: 404, message: 'Not found' }
});
});
});
describe('#update', () => {
const id = 'logstash-*';
const type = 'index-pattern';
const newVersion = 2;
const attributes = { title: 'Testing' };
beforeEach(() => {
callAdminCluster.returns(Promise.resolve({
_id: `${type}:${id}`,
_type: 'doc',
_version: newVersion,
result: 'updated'
}));
});
it('returns current ES document version', async () => {
const response = await savedObjectsClient.update('index-pattern', 'logstash-*', attributes);
expect(response).toEqual({
id,
type,
...mockTimestampFields,
version: newVersion,
attributes
});
});
it('accepts version', async () => {
await savedObjectsClient.update(
type,
id,
{ title: 'Testing' },
{ version: newVersion - 1 }
);
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, sinon.match.string, sinon.match({
version: newVersion - 1
}));
});
it('passes the parameters to callAdminCluster', async () => {
await savedObjectsClient.update('index-pattern', 'logstash-*', { title: 'Testing' });
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'update', {
type: 'doc',
id: 'index-pattern:logstash-*',
version: undefined,
body: {
doc: { updated_at: mockTimestamp, 'index-pattern': { title: 'Testing' } }
},
ignore: [404],
refresh: 'wait_for',
index: '.kibana-test'
});
sinon.assert.calledOnce(onBeforeWrite);
});
});
describe('onBeforeWrite', () => {
it('blocks calls to callCluster of requests', async () => {
onBeforeWrite.returns(delay(500));
callAdminCluster.returns({ result: 'deleted', found: true });
const deletePromise = savedObjectsClient.delete('type', 'id');
await delay(100);
sinon.assert.calledOnce(onBeforeWrite);
sinon.assert.notCalled(callAdminCluster);
await deletePromise;
sinon.assert.calledOnce(onBeforeWrite);
sinon.assert.calledOnce(callAdminCluster);
});
it('can throw es errors and have them decorated as SavedObjectsClient errors', async () => {
expect.assertions(3);
const es401 = new elasticsearch.errors[401];
expect(SavedObjectsClient.errors.isNotAuthorizedError(es401)).toBe(false);
onBeforeWrite.throws(es401);
try {
await savedObjectsClient.delete('type', 'id');
} catch (error) {
sinon.assert.calledOnce(onBeforeWrite);
expect(error).toBe(es401);
expect(SavedObjectsClient.errors.isNotAuthorizedError(error)).toBe(true);
}
});
});
beforeEach(() => {
SavedObjectsRepository.mockClear();
});
const setupMockRepository = (mock) => {
SavedObjectsRepository.mockImplementation(() => mock);
return mock;
};
test(`#constructor`, () => {
const options = {};
new SavedObjectsClient(options);
expect(SavedObjectsRepository).toHaveBeenCalledWith(options);
});
test(`#create`, async () => {
const returnValue = Symbol();
const mockRepository = setupMockRepository({
create: jest.fn().mockReturnValue(Promise.resolve(returnValue)),
});
const client = new SavedObjectsClient();
const type = 'foo';
const attributes = {};
const options = {};
const result = await client.create(type, attributes, options);
expect(mockRepository.create).toHaveBeenCalledWith(type, attributes, options);
expect(result).toBe(returnValue);
});
test(`#bulkCreate`, async () => {
const returnValue = Symbol();
const mockRepository = setupMockRepository({
bulkCreate: jest.fn().mockReturnValue(Promise.resolve(returnValue)),
});
const client = new SavedObjectsClient();
const objects = [];
const options = {};
const result = await client.bulkCreate(objects, options);
expect(mockRepository.bulkCreate).toHaveBeenCalledWith(objects, options);
expect(result).toBe(returnValue);
});
test(`#delete`, async () => {
const returnValue = Symbol();
const mockRepository = setupMockRepository({
delete: jest.fn().mockReturnValue(Promise.resolve(returnValue)),
});
const client = new SavedObjectsClient();
const type = 'foo';
const id = 1;
const result = await client.delete(type, id);
expect(mockRepository.delete).toHaveBeenCalledWith(type, id);
expect(result).toBe(returnValue);
});
test(`#find`, async () => {
const returnValue = Symbol();
const mockRepository = setupMockRepository({
find: jest.fn().mockReturnValue(Promise.resolve(returnValue)),
});
const client = new SavedObjectsClient();
const options = {};
const result = await client.find(options);
expect(mockRepository.find).toHaveBeenCalledWith(options);
expect(result).toBe(returnValue);
});
test(`#bulkGet`, async () => {
const returnValue = Symbol();
const mockRepository = setupMockRepository({
bulkGet: jest.fn().mockReturnValue(Promise.resolve(returnValue)),
});
const client = new SavedObjectsClient();
const objects = {};
const result = await client.bulkGet(objects);
expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects);
expect(result).toBe(returnValue);
});
test(`#get`, async () => {
const returnValue = Symbol();
const mockRepository = setupMockRepository({
get: jest.fn().mockReturnValue(Promise.resolve(returnValue)),
});
const client = new SavedObjectsClient();
const type = 'foo';
const id = 1;
const result = await client.get(type, id);
expect(mockRepository.get).toHaveBeenCalledWith(type, id);
expect(result).toBe(returnValue);
});
test(`#update`, async () => {
const returnValue = Symbol();
const mockRepository = setupMockRepository({
update: jest.fn().mockReturnValue(Promise.resolve(returnValue)),
});
const client = new SavedObjectsClient();
const type = 'foo';
const id = 1;
const attributes = {};
const options = {};
const result = await client.update(type, id, attributes, options);
expect(mockRepository.update).toHaveBeenCalledWith(type, id, attributes, options);
expect(result).toBe(returnValue);
});