From fb2f0569e28740f1ef1786aa75d0134062325c0f Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Thu, 7 Feb 2019 12:43:17 +0100 Subject: [PATCH] Typings for saved object client (#29951) * WIP typings for saved object client * Move more files to TS * type saved objects client * clean up typings for saved object client * tie typings form server and client for saved objects together * add missing html import typing to x-pack * Add missing buildSourcePatterns * Removed accidental comma * add typings for saved_object_client tests and fix test cases * duplicate case_conversion helpers for the moment * Address PR review * Fix some documentation * Replace ts-ignore by any imports * Remove expect.js from test * Add more typings to prevent CI failure --- src/server/saved_objects/index.d.ts | 3 + src/server/saved_objects/service/index.d.ts | 12 +- .../service/saved_objects_client.d.ts | 17 +- src/ui/public/chrome/index.d.ts | 3 +- ...te_index.js => error_auto_create_index.ts} | 20 +- .../{index.js => index.ts} | 0 src/ui/public/kfetch/index.ts | 2 +- src/ui/public/kfetch/kfetch.ts | 2 +- src/ui/public/promises/index.d.ts | 20 + src/ui/public/promises/promises.d.ts | 25 ++ .../__tests__/saved_objects_client.test.js | 368 ------------------ ...ct_by_title.js => find_object_by_title.ts} | 39 +- .../saved_objects/{index.js => index.ts} | 0 src/ui/public/saved_objects/saved_object.js | 67 ---- src/ui/public/saved_objects/saved_object.ts | 81 ++++ ...t_registry.js => saved_object_registry.ts} | 2 +- .../saved_objects/saved_objects_client.js | 260 ------------- .../saved_objects_client.test.ts | 357 +++++++++++++++++ .../saved_objects/saved_objects_client.ts | 358 +++++++++++++++++ ...er.js => saved_objects_client_provider.ts} | 20 +- src/ui/public/utils/case_conversion.ts | 36 ++ x-pack/tsconfig.json | 1 + x-pack/typings/index.d.ts | 10 + 23 files changed, 970 insertions(+), 733 deletions(-) rename src/ui/public/error_auto_create_index/{error_auto_create_index.js => error_auto_create_index.ts} (73%) rename src/ui/public/error_auto_create_index/{index.js => index.ts} (100%) create mode 100644 src/ui/public/promises/index.d.ts create mode 100644 src/ui/public/promises/promises.d.ts delete mode 100644 src/ui/public/saved_objects/__tests__/saved_objects_client.test.js rename src/ui/public/saved_objects/{find_object_by_title.js => find_object_by_title.ts} (62%) rename src/ui/public/saved_objects/{index.js => index.ts} (100%) delete mode 100644 src/ui/public/saved_objects/saved_object.js create mode 100644 src/ui/public/saved_objects/saved_object.ts rename src/ui/public/saved_objects/{saved_object_registry.js => saved_object_registry.ts} (96%) delete mode 100644 src/ui/public/saved_objects/saved_objects_client.js create mode 100644 src/ui/public/saved_objects/saved_objects_client.test.ts create mode 100644 src/ui/public/saved_objects/saved_objects_client.ts rename src/ui/public/saved_objects/{saved_objects_client_provider.js => saved_objects_client_provider.ts} (70%) create mode 100644 src/ui/public/utils/case_conversion.ts create mode 100644 x-pack/typings/index.d.ts diff --git a/src/server/saved_objects/index.d.ts b/src/server/saved_objects/index.d.ts index 99b6af3c787a..8e6df957b8a6 100644 --- a/src/server/saved_objects/index.d.ts +++ b/src/server/saved_objects/index.d.ts @@ -18,8 +18,11 @@ */ export { + MigrationVersion, SavedObject, + SavedObjectAttributes, SavedObjectsClient, SavedObjectsClientWrapperFactory, + SavedObjectReference, SavedObjectsService, } from './service'; diff --git a/src/server/saved_objects/service/index.d.ts b/src/server/saved_objects/service/index.d.ts index dc6b2c545ca7..47f3f382c9da 100644 --- a/src/server/saved_objects/service/index.d.ts +++ b/src/server/saved_objects/service/index.d.ts @@ -19,4 +19,14 @@ export { SavedObjectsService } from './create_saved_objects_service'; export { SavedObjectsClientWrapperFactory } from './lib'; -export { SavedObject, SavedObjectsClient } from './saved_objects_client'; +export { + FindOptions, + GetResponse, + UpdateResponse, + CreateResponse, + MigrationVersion, + SavedObject, + SavedObjectAttributes, + SavedObjectsClient, + SavedObjectReference, +} from './saved_objects_client'; diff --git a/src/server/saved_objects/service/saved_objects_client.d.ts b/src/server/saved_objects/service/saved_objects_client.d.ts index 6f3578acb6e4..2d823276a6f7 100644 --- a/src/server/saved_objects/service/saved_objects_client.d.ts +++ b/src/server/saved_objects/service/saved_objects_client.d.ts @@ -26,6 +26,7 @@ export interface BaseOptions { export interface CreateOptions extends BaseOptions { id?: string; override?: boolean; + references?: SavedObjectReference[]; } export interface BulkCreateObject { @@ -48,6 +49,7 @@ export interface FindOptions extends BaseOptions { fields?: string[]; search?: string; searchFields?: string[]; + hasReference?: { type: string; id: string }; } export interface FindResponse { @@ -71,6 +73,10 @@ export interface BulkGetResponse { saved_objects: Array>; } +export interface MigrationVersion { + [pluginName: string]: string; +} + export interface SavedObjectAttributes { [key: string]: SavedObjectAttributes | string | number | boolean | null; } @@ -85,6 +91,7 @@ export interface SavedObject { }; attributes: T; references: SavedObjectReference[]; + migrationVersion?: MigrationVersion; } export interface SavedObjectReference { @@ -93,6 +100,10 @@ export interface SavedObjectReference { id: string; } +export type GetResponse = SavedObject; +export type CreateResponse = SavedObject; +export type UpdateResponse = SavedObject; + export declare class SavedObjectsClient { public static errors: typeof errors; public errors: typeof errors; @@ -103,7 +114,7 @@ export declare class SavedObjectsClient { type: string, attributes: T, options?: CreateOptions - ): Promise>; + ): Promise>; public bulkCreate( objects: Array>, options?: CreateOptions @@ -120,11 +131,11 @@ export declare class SavedObjectsClient { type: string, id: string, options?: BaseOptions - ): Promise>; + ): Promise>; public update( type: string, id: string, attributes: Partial, options?: UpdateOptions - ): Promise>; + ): Promise>; } diff --git a/src/ui/public/chrome/index.d.ts b/src/ui/public/chrome/index.d.ts index 73a9f406854a..c93e8a693ab7 100644 --- a/src/ui/public/chrome/index.d.ts +++ b/src/ui/public/chrome/index.d.ts @@ -18,6 +18,7 @@ */ import { Brand } from '../../../core/public/chrome'; +import { SavedObjectsClient } from '../saved_objects'; import { BreadcrumbsApi } from './api/breadcrumbs'; import { HelpExtensionApi } from './api/help_extension'; import { ChromeNavLinks } from './api/nav'; @@ -34,6 +35,7 @@ declare interface Chrome extends ChromeNavLinks { getBasePath(): string; getXsrfToken(): string; getKibanaVersion(): string; + getSavedObjectsClient(): SavedObjectsClient; getUiSettingsClient(): any; setVisible(visible: boolean): any; getInjected(key: string, defaultValue?: any): any; @@ -43,7 +45,6 @@ declare interface Chrome extends ChromeNavLinks { addApplicationClass(classNames: string | string[]): this; removeApplicationClass(classNames: string | string[]): this; getApplicationClasses(): string; - getSavedObjectsClient(): any; } declare const chrome: Chrome; diff --git a/src/ui/public/error_auto_create_index/error_auto_create_index.js b/src/ui/public/error_auto_create_index/error_auto_create_index.ts similarity index 73% rename from src/ui/public/error_auto_create_index/error_auto_create_index.js rename to src/ui/public/error_auto_create_index/error_auto_create_index.ts index 4c213d096b02..e5287f18e1f7 100644 --- a/src/ui/public/error_auto_create_index/error_auto_create_index.js +++ b/src/ui/public/error_auto_create_index/error_auto_create_index.ts @@ -24,16 +24,20 @@ import uiRoutes from '../routes'; import template from './error_auto_create_index.html'; -uiRoutes - .when('/error/action.auto_create_index', { - template, - k7Breadcrumbs: () => [{ text: i18n.translate('common.ui.errorAutoCreateIndex.breadcrumbs.errorText', { defaultMessage: 'Error' }) }], - }); +uiRoutes.when('/error/action.auto_create_index', { + template, + k7Breadcrumbs: () => [ + { + text: i18n.translate('common.ui.errorAutoCreateIndex.breadcrumbs.errorText', { + defaultMessage: 'Error', + }), + }, + ], +}); -export function isAutoCreateIndexError(error) { +export function isAutoCreateIndexError(error: object) { return ( - get(error, 'res.status') === 503 && - get(error, 'body.code') === 'ES_AUTO_CREATE_INDEX_ERROR' + get(error, 'res.status') === 503 && get(error, 'body.code') === 'ES_AUTO_CREATE_INDEX_ERROR' ); } diff --git a/src/ui/public/error_auto_create_index/index.js b/src/ui/public/error_auto_create_index/index.ts similarity index 100% rename from src/ui/public/error_auto_create_index/index.js rename to src/ui/public/error_auto_create_index/index.ts diff --git a/src/ui/public/kfetch/index.ts b/src/ui/public/kfetch/index.ts index f871d97f0fd5..234304b5950a 100644 --- a/src/ui/public/kfetch/index.ts +++ b/src/ui/public/kfetch/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { kfetch, addInterceptor, KFetchOptions } from './kfetch'; +export { kfetch, addInterceptor, KFetchOptions, KFetchQuery } from './kfetch'; export { kfetchAbortable } from './kfetch_abortable'; diff --git a/src/ui/public/kfetch/kfetch.ts b/src/ui/public/kfetch/kfetch.ts index 01515c9c0e0a..086692a66261 100644 --- a/src/ui/public/kfetch/kfetch.ts +++ b/src/ui/public/kfetch/kfetch.ts @@ -24,7 +24,7 @@ import url from 'url'; import chrome from '../chrome'; import { KFetchError } from './kfetch_error'; -interface KFetchQuery { +export interface KFetchQuery { [key: string]: string | number | boolean | undefined; } diff --git a/src/ui/public/promises/index.d.ts b/src/ui/public/promises/index.d.ts new file mode 100644 index 000000000000..aa81770056cc --- /dev/null +++ b/src/ui/public/promises/index.d.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { PromiseService } from './promises'; diff --git a/src/ui/public/promises/promises.d.ts b/src/ui/public/promises/promises.d.ts new file mode 100644 index 000000000000..84d81b39ec37 --- /dev/null +++ b/src/ui/public/promises/promises.d.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface PromiseService { + resolve: (value: T | PromiseLike) => ng.IPromise; + + // TODO: add additional typing + [key: string]: any; +} diff --git a/src/ui/public/saved_objects/__tests__/saved_objects_client.test.js b/src/ui/public/saved_objects/__tests__/saved_objects_client.test.js deleted file mode 100644 index 6819e9b7c4ad..000000000000 --- a/src/ui/public/saved_objects/__tests__/saved_objects_client.test.js +++ /dev/null @@ -1,368 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('ui/kfetch', () => ({})); - -import sinon from 'sinon'; -import expect from 'expect.js'; -import { SavedObjectsClient } from '../saved_objects_client'; -import { SavedObject } from '../saved_object'; - -describe('SavedObjectsClient', () => { - const doc = { - id: 'AVwSwFxtcMV38qjDZoQg', - type: 'config', - attributes: { title: 'Example title' }, - version: 'foo' - }; - - let kfetchStub; - let savedObjectsClient; - beforeEach(() => { - kfetchStub = sinon.stub(); - require('ui/kfetch').kfetch = async (...args) => { - return kfetchStub(...args); - }; - savedObjectsClient = new SavedObjectsClient(); - }); - - describe('#_getPath', () => { - test('returns without arguments', () => { - const path = savedObjectsClient._getPath(); - const expected = `/api/saved_objects/`; - - expect(path).to.be(expected); - }); - - test('appends path', () => { - const path = savedObjectsClient._getPath(['some', 'path']); - const expected = `/api/saved_objects/some/path`; - - expect(path).to.be(expected); - }); - }); - - describe('#_request', () => { - const body = { foo: 'Foo', bar: 'Bar' }; - - test('passes options to kfetch', () => { - kfetchStub.withArgs({ - method: 'POST', - pathname: '/api/path', - query: undefined, - body: JSON.stringify(body) - }).returns(Promise.resolve({})); - - savedObjectsClient._request({ method: 'POST', path: '/api/path', body }); - - sinon.assert.calledOnce(kfetchStub); - }); - - test('throws error when body is provided for GET', async () => { - try { - await savedObjectsClient._request({ method: 'GET', path: '/api/path', body }); - expect().fail('should have error'); - } catch (e) { - expect(e.message).to.eql('body not permitted for GET requests'); - } - }); - }); - - describe('#get', () => { - beforeEach(() => { - kfetchStub.withArgs({ - method: 'POST', - pathname: `/api/saved_objects/_bulk_get`, - query: undefined, - body: sinon.match.any - }).returns(Promise.resolve({ saved_objects: [doc] })); - }); - - test('returns a promise', () => { - expect(savedObjectsClient.get('index-pattern', 'logstash-*')).to.be.a(Promise); - }); - - test('requires type', async () => { - try { - await savedObjectsClient.get(); - expect().fail('should have error'); - } catch (e) { - expect(e.message).to.be('requires type and id'); - } - }); - - test('requires id', async () => { - try { - await savedObjectsClient.get('index-pattern'); - expect().throw('should have error'); - } catch (e) { - expect(e.message).to.be('requires type and id'); - } - }); - - test('resolves with instantiated SavedObject', async () => { - const response = await savedObjectsClient.get(doc.type, doc.id); - expect(response).to.be.a(SavedObject); - expect(response.type).to.eql('config'); - expect(response.get('title')).to.eql('Example title'); - expect(response._client).to.be.a(SavedObjectsClient); - }); - - test('makes HTTP call', async () => { - await savedObjectsClient.get(doc.type, doc.id); - sinon.assert.calledOnce(kfetchStub); - }); - - test('handles HTTP call when it fails', async () => { - kfetchStub.withArgs({ - method: 'POST', - pathname: `/api/saved_objects/_bulk_get`, - query: undefined, - body: sinon.match.any - }).rejects(new Error('Request failed')); - try { - await savedObjectsClient.get(doc.type, doc.id); - throw new Error('should have error'); - } catch (e) { - expect(e.message).to.be('Request failed'); - } - }); - }); - - describe('#delete', () => { - beforeEach(() => { - kfetchStub.withArgs({ - method: 'DELETE', - pathname: `/api/saved_objects/index-pattern/logstash-*`, - query: undefined, - body: undefined, - }).returns(Promise.resolve({})); - }); - - test('returns a promise', () => { - expect(savedObjectsClient.delete('index-pattern', 'logstash-*')).to.be.a(Promise); - }); - - test('requires type', async () => { - try { - await savedObjectsClient.delete(); - expect().throw('should have error'); - } catch (e) { - expect(e.message).to.be('requires type and id'); - } - }); - - test('requires id', async () => { - try { - await savedObjectsClient.delete('index-pattern'); - expect().throw('should have error'); - } catch (e) { - expect(e.message).to.be('requires type and id'); - } - }); - - test('makes HTTP call', () => { - savedObjectsClient.delete('index-pattern', 'logstash-*'); - sinon.assert.calledOnce(kfetchStub); - }); - }); - - describe('#update', () => { - const requireMessage = 'requires type, id and attributes'; - - beforeEach(() => { - kfetchStub.withArgs({ - method: 'PUT', - pathname: `/api/saved_objects/index-pattern/logstash-*`, - query: undefined, - body: sinon.match.any - }).returns(Promise.resolve({ data: 'api-response' })); - }); - - test('returns a promise', () => { - expect(savedObjectsClient.update('index-pattern', 'logstash-*', {})).to.be.a(Promise); - }); - - test('requires type', async () => { - try { - await savedObjectsClient.update(); - expect().throw('should have error'); - } catch (e) { - expect(e.message).to.be(requireMessage); - } - }); - - test('requires id', async () => { - try { - await savedObjectsClient.update('index-pattern'); - expect().throw('should have error'); - } catch (e) { - expect(e.message).to.be(requireMessage); - } - }); - - test('requires attributes', async () => { - try { - await savedObjectsClient.update('index-pattern', 'logstash-*'); - expect().throw('should have error'); - } catch (e) { - expect(e.message).to.be(requireMessage); - } - }); - - test('makes HTTP call', () => { - const attributes = { foo: 'Foo', bar: 'Bar' }; - const body = { attributes, version: 'foo' }; - const options = { version: 'foo' }; - - savedObjectsClient.update('index-pattern', 'logstash-*', attributes, options); - sinon.assert.calledOnce(kfetchStub); - sinon.assert.calledWithExactly(kfetchStub, sinon.match({ - body: JSON.stringify(body) - })); - }); - }); - - describe('#create', () => { - const requireMessage = 'requires type and attributes'; - - beforeEach(() => { - kfetchStub.withArgs({ - method: 'POST', - pathname: `/api/saved_objects/index-pattern`, - query: undefined, - body: sinon.match.any - }).returns(Promise.resolve({})); - }); - - test('returns a promise', () => { - expect(savedObjectsClient.create('index-pattern', {})).to.be.a(Promise); - }); - - test('requires type', async () => { - try { - await savedObjectsClient.create(); - expect().throw('should have error'); - } catch (e) { - expect(e.message).to.be(requireMessage); - } - }); - - test('allows for id to be provided', () => { - const attributes = { foo: 'Foo', bar: 'Bar' }; - const path = `/api/saved_objects/index-pattern/myId`; - kfetchStub.withArgs({ - method: 'POST', - pathname: path, - query: undefined, - body: sinon.match.any - }).returns(Promise.resolve({})); - - savedObjectsClient.create('index-pattern', attributes, { id: 'myId' }); - - sinon.assert.calledOnce(kfetchStub); - sinon.assert.calledWithExactly(kfetchStub, sinon.match({ - pathname: path - })); - }); - - test('makes HTTP call', () => { - const attributes = { foo: 'Foo', bar: 'Bar' }; - savedObjectsClient.create('index-pattern', attributes); - - sinon.assert.calledOnce(kfetchStub); - sinon.assert.calledWithExactly(kfetchStub, sinon.match({ - pathname: sinon.match.string, - body: JSON.stringify({ attributes }), - })); - }); - }); - - describe('#bulk_create', () => { - beforeEach(() => { - kfetchStub.withArgs({ - method: 'POST', - pathname: `/api/saved_objects/_bulk_create`, - query: sinon.match.any, - body: sinon.match.any - }).returns(Promise.resolve({ saved_objects: [doc] })); - }); - - test('returns a promise', () => { - expect(savedObjectsClient.bulkCreate([doc], {})).to.be.a(Promise); - }); - - test('resolves with instantiated SavedObjects', async () => { - const response = await savedObjectsClient.bulkCreate([doc], {}); - expect(response).to.have.property('savedObjects'); - expect(response.savedObjects.length).to.eql(1); - expect(response.savedObjects[0]).to.be.a(SavedObject); - }); - - test('makes HTTP call', async () => { - await savedObjectsClient.bulkCreate([doc], {}); - sinon.assert.calledOnce(kfetchStub); - }); - }); - - describe('#find', () => { - const object = { id: 'logstash-*', type: 'index-pattern', title: 'Test' }; - - beforeEach(() => { - kfetchStub.returns(Promise.resolve({ saved_objects: [object] })); - }); - - test('returns a promise', () => { - expect(savedObjectsClient.find()).to.be.a(Promise); - }); - - test('accepts type', () => { - const body = { type: 'index-pattern', invalid: true }; - - savedObjectsClient.find(body); - sinon.assert.calledOnce(kfetchStub); - sinon.assert.calledWithExactly(kfetchStub, sinon.match({ - pathname: `/api/saved_objects/_find`, - query: { type: 'index-pattern', invalid: true } - })); - }); - - test('accepts fields', () => { - const body = { fields: ['title', 'description'] }; - - savedObjectsClient.find(body); - sinon.assert.calledOnce(kfetchStub); - sinon.assert.calledWithExactly(kfetchStub, sinon.match({ - pathname: `/api/saved_objects/_find`, - query: { fields: [ 'title', 'description' ] } - })); - }); - - test('accepts from/size', () => { - const body = { from: 50, size: 10 }; - - savedObjectsClient.find(body); - sinon.assert.calledOnce(kfetchStub); - sinon.assert.alwaysCalledWith(kfetchStub, sinon.match({ - pathname: `/api/saved_objects/_find`, - query: { from: 50, size: 10 } - })); - }); - }); -}); diff --git a/src/ui/public/saved_objects/find_object_by_title.js b/src/ui/public/saved_objects/find_object_by_title.ts similarity index 62% rename from src/ui/public/saved_objects/find_object_by_title.js rename to src/ui/public/saved_objects/find_object_by_title.ts index 6b148d615c71..de6829bdc76e 100644 --- a/src/ui/public/saved_objects/find_object_by_title.js +++ b/src/ui/public/saved_objects/find_object_by_title.ts @@ -18,6 +18,9 @@ */ import { find } from 'lodash'; +import { SavedObjectAttributes } from '../../../server/saved_objects'; +import { SavedObject } from './saved_object'; +import { SavedObjectsClient } from './saved_objects_client'; /** * Returns an object matching a given title @@ -27,22 +30,30 @@ import { find } from 'lodash'; * @param title {string} * @returns {Promise} */ -export function findObjectByTitle(savedObjectsClient, type, title) { - if (!title) return Promise.resolve(); +export function findObjectByTitle( + savedObjectsClient: SavedObjectsClient, + type: string, + title: string +): Promise | void> { + if (!title) { + return Promise.resolve(); + } // Elastic search will return the most relevant results first, which means exact matches should come // first, and so we shouldn't need to request everything. Using 10 just to be on the safe side. - return savedObjectsClient.find({ - type, - perPage: 10, - search: `"${title}"`, - searchFields: ['title'], - fields: ['title'] - }).then(response => { - const match = find(response.savedObjects, (obj) => { - return obj.get('title').toLowerCase() === title.toLowerCase(); - }); + return savedObjectsClient + .find({ + type, + perPage: 10, + search: `"${title}"`, + searchFields: ['title'], + fields: ['title'], + }) + .then(response => { + const match = find(response.savedObjects, obj => { + return obj.get('title').toLowerCase() === title.toLowerCase(); + }); - return match; - }); + return match; + }); } diff --git a/src/ui/public/saved_objects/index.js b/src/ui/public/saved_objects/index.ts similarity index 100% rename from src/ui/public/saved_objects/index.js rename to src/ui/public/saved_objects/index.ts diff --git a/src/ui/public/saved_objects/saved_object.js b/src/ui/public/saved_objects/saved_object.js deleted file mode 100644 index ed3bbfe9b0fb..000000000000 --- a/src/ui/public/saved_objects/saved_object.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -export class SavedObject { - constructor(client, { id, type, version, attributes, error, migrationVersion, references } = {}) { - this._client = client; - this.id = id; - this.type = type; - this.attributes = attributes || {}; - this.references = references || []; - this._version = version; - this.migrationVersion = migrationVersion; - if (error) { - this.error = error; - } - } - - get(key) { - return _.get(this.attributes, key); - } - - set(key, value) { - return _.set(this.attributes, key, value); - } - - has(key) { - return _.has(this.attributes, key); - } - - save() { - if (this.id) { - return this._client.update( - this.type, - this.id, - this.attributes, - { - migrationVersion: this.migrationVersion, - references: this.references, - }, - ); - } else { - return this._client.create(this.type, this.attributes, { migrationVersion: this.migrationVersion, references: this.references }); - } - } - - delete() { - return this._client.delete(this.type, this.id); - } -} diff --git a/src/ui/public/saved_objects/saved_object.ts b/src/ui/public/saved_objects/saved_object.ts new file mode 100644 index 000000000000..a34cc2f150e5 --- /dev/null +++ b/src/ui/public/saved_objects/saved_object.ts @@ -0,0 +1,81 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get, has, set } from 'lodash'; +import { + SavedObject as SavedObjectType, + SavedObjectAttributes, +} from '../../../server/saved_objects'; +import { SavedObjectsClient } from './saved_objects_client'; + +export class SavedObject { + public attributes: T; + // tslint:disable-next-line variable-name We want to use the same interface this class had in JS + public _version?: SavedObjectType['version']; + public id: SavedObjectType['id']; + public type: SavedObjectType['type']; + public migrationVersion: SavedObjectType['migrationVersion']; + public error: SavedObjectType['error']; + public references: SavedObjectType['references']; + + constructor( + private client: SavedObjectsClient, + { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType + ) { + this.id = id; + this.type = type; + this.attributes = attributes || {}; + this.references = references || []; + this._version = version; + this.migrationVersion = migrationVersion; + if (error) { + this.error = error; + } + } + + public get(key: string): any { + return get(this.attributes, key); + } + + public set(key: string, value: any): T { + return set(this.attributes, key, value); + } + + public has(key: string): boolean { + return has(this.attributes, key); + } + + public save() { + if (this.id) { + return this.client.update(this.type, this.id, this.attributes, { + migrationVersion: this.migrationVersion, + references: this.references, + }); + } else { + return this.client.create(this.type, this.attributes, { + migrationVersion: this.migrationVersion, + references: this.references, + }); + } + } + + public delete() { + return this.client.delete(this.type, this.id); + } +} diff --git a/src/ui/public/saved_objects/saved_object_registry.js b/src/ui/public/saved_objects/saved_object_registry.ts similarity index 96% rename from src/ui/public/saved_objects/saved_object_registry.js rename to src/ui/public/saved_objects/saved_object_registry.ts index 08cf9753c8a0..34b91267bfb3 100644 --- a/src/ui/public/saved_objects/saved_object_registry.js +++ b/src/ui/public/saved_objects/saved_object_registry.ts @@ -22,5 +22,5 @@ import { uiRegistry } from '../registry/_registry'; export const SavedObjectRegistryProvider = uiRegistry({ name: 'savedObjects', index: ['loaderProperties.name'], - order: ['loaderProperties.name'] + order: ['loaderProperties.name'], }); diff --git a/src/ui/public/saved_objects/saved_objects_client.js b/src/ui/public/saved_objects/saved_objects_client.js deleted file mode 100644 index 0ee4c2ff47ad..000000000000 --- a/src/ui/public/saved_objects/saved_objects_client.js +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -import { resolve as resolveUrl } from 'url'; -import { keysToSnakeCaseShallow, keysToCamelCaseShallow } from '../../../utils/case_conversion'; -import { SavedObject } from './saved_object'; -import { isAutoCreateIndexError, showAutoCreateIndexErrorPage } from '../error_auto_create_index'; -import { kfetch } from 'ui/kfetch'; - -const join = (...uriComponents) => ( - uriComponents.filter(Boolean).map(encodeURIComponent).join('/') -); - -/** - * Interval that requests are batched for - * @type {integer} - */ -const BATCH_INTERVAL = 100; - -const API_BASE_URL = '/api/saved_objects/'; - -export class SavedObjectsClient { - constructor() { - this.batchQueue = []; - } - - /** - * 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] - * @property {object} [options.migrationVersion] - * @property {array} [options.references] [{ name, type, id }] - * @returns {promise} - SavedObject({ id, type, version, attributes }) - */ - create = (type, attributes = {}, options = {}) => { - if (!type || !attributes) { - return Promise.reject(new Error('requires type and attributes')); - } - - const path = this._getPath([type, options.id]); - const query = _.pick(options, ['overwrite']); - - return this - ._request({ - method: 'POST', - path, - query, - body: { - attributes, - migrationVersion: options.migrationVersion, - references: options.references, - }, - }) - .catch(error => { - if (isAutoCreateIndexError(error)) { - return showAutoCreateIndexErrorPage(); - } - - throw error; - }) - .then(resp => this._createSavedObject(resp)); - } - - /** - * Creates multiple documents at once - * - * @param {array} objects - [{ type, id, attributes, references, migrationVersion }] - * @param {object} [options={}] - * @property {boolean} [options.overwrite=false] - * @returns {promise} - { savedObjects: [{ id, type, version, attributes, error: { message } }]} - */ - bulkCreate = (objects = [], options = {}) => { - const path = this._getPath(['_bulk_create']); - const query = _.pick(options, ['overwrite']); - - return this._request({ method: 'POST', path, query, body: objects }).then(resp => { - resp.saved_objects = resp.saved_objects.map(d => this._createSavedObject(d)); - return keysToCamelCaseShallow(resp); - }); - } - - /** - * Deletes an object - * - * @param {string} type - * @param {string} id - * @returns {promise} - */ - delete = (type, id) => { - if (!type || !id) { - return Promise.reject(new Error('requires type and id')); - } - - return this._request({ method: 'DELETE', path: this._getPath([type, id]) }); - } - - /** - * Search for objects - * - * @param {object} [options={}] - * @property {string} options.type - * @property {string} options.search - * @property {string} options.defaultSearchOperator - * @property {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 {array} options.fields - * @property {object} [options.hasReference] - { type, id } - * @returns {promise} - { savedObjects: [ SavedObject({ id, type, version, attributes }) ]} - */ - find = (options = {}) => { - const path = this._getPath(['_find']); - const query = keysToSnakeCaseShallow(options); - - return this._request({ method: 'GET', path, query }).then(resp => { - resp.saved_objects = resp.saved_objects.map(d => this._createSavedObject(d)); - return keysToCamelCaseShallow(resp); - }); - } - - /** - * Fetches a single object - * - * @param {string} type - * @param {string} id - * @returns {promise} - SavedObject({ id, type, version, attributes }) - */ - get = (type, id) => { - if (!type || !id) { - return Promise.reject(new Error('requires type and id')); - } - - return new Promise((resolve, reject) => { - this.batchQueue.push({ type, id, resolve, reject }); - this._processBatchQueue(); - }); - } - - /** - * 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} - { savedObjects: [ SavedObject({ id, type, version, attributes }) ] } - * @example - * - * bulkGet([ - * { id: 'one', type: 'config' }, - * { id: 'foo', type: 'index-pattern' } - * ]) - */ - bulkGet = (objects = []) => { - const path = this._getPath(['_bulk_get']); - const filteredObjects = objects.map(obj => _.pick(obj, ['id', 'type'])); - - return this._request({ method: 'POST', path, body: filteredObjects }).then(resp => { - resp.saved_objects = resp.saved_objects.map(d => this._createSavedObject(d)); - return keysToCamelCaseShallow(resp); - }); - } - - /** - * Updates an object - * - * @param {string} type - * @param {string} id - * @param {object} attributes - * @param {object} options - * @prop {integer} options.version - ensures version matches that of persisted object - * @prop {object} options.migrationVersion - The optional migrationVersion of this document - * @prop {array} option.references - the references of the saved object - * @returns {promise} - */ - update(type, id, attributes, { version, migrationVersion, references } = {}) { - if (!type || !id || !attributes) { - return Promise.reject(new Error('requires type, id and attributes')); - } - - const path = this._getPath([type, id]); - const body = { - attributes, - migrationVersion, - references, - version - }; - - return this._request({ method: 'PUT', path, body }).then(resp => { - return this._createSavedObject(resp); - }); - } - - /** - * Throttled processing of get requests into bulk requests at 100ms interval - */ - _processBatchQueue = _.throttle(() => { - const queue = _.cloneDeep(this.batchQueue); - this.batchQueue = []; - - this.bulkGet(queue).then(({ savedObjects }) => { - queue.forEach((queueItem) => { - const foundObject = savedObjects.find(savedObject => { - return savedObject.id === queueItem.id & savedObject.type === queueItem.type; - }); - - if (!foundObject) { - return queueItem.resolve(this._createSavedObject(_.pick(queueItem, ['id', 'type']))); - } - - queueItem.resolve(foundObject); - }); - }).catch((err) => { - queue.forEach((queueItem) => { - queueItem.reject(err); - }); - }); - - }, BATCH_INTERVAL, { leading: false }); - - _createSavedObject(options) { - return new SavedObject(this, options); - } - - _getPath(path) { - if (!path) { - return API_BASE_URL; - } - - return resolveUrl(API_BASE_URL, join(...path)); - } - - _request({ method, path, query, body }) { - if (method === 'GET' && body) { - return Promise.reject(new Error('body not permitted for GET requests')); - } - - return kfetch({ method, pathname: path, query, body: JSON.stringify(body) }); - } -} diff --git a/src/ui/public/saved_objects/saved_objects_client.test.ts b/src/ui/public/saved_objects/saved_objects_client.test.ts new file mode 100644 index 000000000000..fe8d144407d8 --- /dev/null +++ b/src/ui/public/saved_objects/saved_objects_client.test.ts @@ -0,0 +1,357 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('ui/kfetch', () => ({})); + +import * as sinon from 'sinon'; +import { FindOptions } from '../../../server/saved_objects/service'; +import { SavedObject } from './saved_object'; +import { SavedObjectsClient } from './saved_objects_client'; + +describe('SavedObjectsClient', () => { + const doc = { + id: 'AVwSwFxtcMV38qjDZoQg', + type: 'config', + attributes: { title: 'Example title' }, + version: 'foo', + }; + + let kfetchStub: sinon.SinonStub; + let savedObjectsClient: SavedObjectsClient; + beforeEach(() => { + kfetchStub = sinon.stub(); + require('ui/kfetch').kfetch = async (...args: any[]) => { + return kfetchStub(...args); + }; + savedObjectsClient = new SavedObjectsClient(); + }); + + describe('#get', () => { + beforeEach(() => { + kfetchStub + .withArgs({ + method: 'POST', + pathname: `/api/saved_objects/_bulk_get`, + query: undefined, + body: sinon.match.any, + }) + .returns(Promise.resolve({ saved_objects: [doc] })); + }); + + test('returns a promise', () => { + expect(savedObjectsClient.get('index-pattern', 'logstash-*')).toBeInstanceOf(Promise); + }); + + test('requires type', async () => { + try { + await savedObjectsClient.get(undefined as any, undefined as any); + fail('should have error'); + } catch (e) { + expect(e.message).toBe('requires type and id'); + } + }); + + test('requires id', async () => { + try { + await savedObjectsClient.get('index-pattern', undefined as any); + fail('should have error'); + } catch (e) { + expect(e.message).toBe('requires type and id'); + } + }); + + test('resolves with instantiated SavedObject', async () => { + const response = await savedObjectsClient.get(doc.type, doc.id); + expect(response).toBeInstanceOf(SavedObject); + expect(response.type).toBe('config'); + expect(response.get('title')).toBe('Example title'); + }); + + test('makes HTTP call', async () => { + await savedObjectsClient.get(doc.type, doc.id); + sinon.assert.calledOnce(kfetchStub); + }); + + test('handles HTTP call when it fails', async () => { + kfetchStub + .withArgs({ + method: 'POST', + pathname: `/api/saved_objects/_bulk_get`, + query: undefined, + body: sinon.match.any, + }) + .rejects(new Error('Request failed')); + try { + await savedObjectsClient.get(doc.type, doc.id); + throw new Error('should have error'); + } catch (e) { + expect(e.message).toBe('Request failed'); + } + }); + }); + + describe('#delete', () => { + beforeEach(() => { + kfetchStub + .withArgs({ + method: 'DELETE', + pathname: `/api/saved_objects/index-pattern/logstash-*`, + query: undefined, + body: undefined, + }) + .returns(Promise.resolve({})); + }); + + test('returns a promise', () => { + expect(savedObjectsClient.delete('index-pattern', 'logstash-*')).toBeInstanceOf(Promise); + }); + + test('requires type', async () => { + try { + await savedObjectsClient.delete(undefined as any, undefined as any); + fail('should have error'); + } catch (e) { + expect(e.message).toBe('requires type and id'); + } + }); + + test('requires id', async () => { + try { + await savedObjectsClient.delete('index-pattern', undefined as any); + fail('should have error'); + } catch (e) { + expect(e.message).toBe('requires type and id'); + } + }); + + test('makes HTTP call', () => { + savedObjectsClient.delete('index-pattern', 'logstash-*'); + sinon.assert.calledOnce(kfetchStub); + }); + }); + + describe('#update', () => { + const requireMessage = 'requires type, id and attributes'; + + beforeEach(() => { + kfetchStub + .withArgs({ + method: 'PUT', + pathname: `/api/saved_objects/index-pattern/logstash-*`, + query: undefined, + body: sinon.match.any, + }) + .returns(Promise.resolve({ data: 'api-response' })); + }); + + test('returns a promise', () => { + expect(savedObjectsClient.update('index-pattern', 'logstash-*', {})).toBeInstanceOf(Promise); + }); + + test('requires type', async () => { + try { + await savedObjectsClient.update(undefined as any, undefined as any, undefined as any); + fail('should have error'); + } catch (e) { + expect(e.message).toBe(requireMessage); + } + }); + + test('requires id', async () => { + try { + await savedObjectsClient.update('index-pattern', undefined as any, undefined as any); + fail('should have error'); + } catch (e) { + expect(e.message).toBe(requireMessage); + } + }); + + test('requires attributes', async () => { + try { + await savedObjectsClient.update('index-pattern', 'logstash-*', undefined as any); + fail('should have error'); + } catch (e) { + expect(e.message).toBe(requireMessage); + } + }); + + test('makes HTTP call', () => { + const attributes = { foo: 'Foo', bar: 'Bar' }; + const body = { attributes, version: 'foo' }; + const options = { version: 'foo' }; + + savedObjectsClient.update('index-pattern', 'logstash-*', attributes, options); + sinon.assert.calledOnce(kfetchStub); + sinon.assert.calledWithExactly( + kfetchStub, + sinon.match({ + body: JSON.stringify(body), + }) + ); + }); + }); + + describe('#create', () => { + const requireMessage = 'requires type and attributes'; + + beforeEach(() => { + kfetchStub + .withArgs({ + method: 'POST', + pathname: `/api/saved_objects/index-pattern`, + query: undefined, + body: sinon.match.any, + }) + .returns(Promise.resolve({})); + }); + + test('returns a promise', () => { + expect(savedObjectsClient.create('index-pattern', {})).toBeInstanceOf(Promise); + }); + + test('requires type', async () => { + try { + await savedObjectsClient.create(undefined as any, undefined as any); + fail('should have error'); + } catch (e) { + expect(e.message).toBe(requireMessage); + } + }); + + test('allows for id to be provided', () => { + const attributes = { foo: 'Foo', bar: 'Bar' }; + const path = `/api/saved_objects/index-pattern/myId`; + kfetchStub + .withArgs({ + method: 'POST', + pathname: path, + query: undefined, + body: sinon.match.any, + }) + .returns(Promise.resolve({})); + + savedObjectsClient.create('index-pattern', attributes, { id: 'myId' }); + + sinon.assert.calledOnce(kfetchStub); + sinon.assert.calledWithExactly( + kfetchStub, + sinon.match({ + pathname: path, + }) + ); + }); + + test('makes HTTP call', () => { + const attributes = { foo: 'Foo', bar: 'Bar' }; + savedObjectsClient.create('index-pattern', attributes); + + sinon.assert.calledOnce(kfetchStub); + sinon.assert.calledWithExactly( + kfetchStub, + sinon.match({ + pathname: sinon.match.string, + body: JSON.stringify({ attributes }), + }) + ); + }); + }); + + describe('#bulk_create', () => { + beforeEach(() => { + kfetchStub + .withArgs({ + method: 'POST', + pathname: `/api/saved_objects/_bulk_create`, + query: sinon.match.any, + body: sinon.match.any, + }) + .returns(Promise.resolve({ saved_objects: [doc] })); + }); + + test('returns a promise', () => { + expect(savedObjectsClient.bulkCreate([doc], {})).toBeInstanceOf(Promise); + }); + + test('resolves with instantiated SavedObjects', async () => { + const response = await savedObjectsClient.bulkCreate([doc], {}); + expect(response).toHaveProperty('savedObjects'); + expect(response.savedObjects.length).toBe(1); + expect(response.savedObjects[0]).toBeInstanceOf(SavedObject); + }); + + test('makes HTTP call', async () => { + await savedObjectsClient.bulkCreate([doc], {}); + sinon.assert.calledOnce(kfetchStub); + }); + }); + + describe('#find', () => { + const object = { id: 'logstash-*', type: 'index-pattern', title: 'Test' }; + + beforeEach(() => { + kfetchStub.returns(Promise.resolve({ saved_objects: [object] })); + }); + + test('returns a promise', () => { + expect(savedObjectsClient.find()).toBeInstanceOf(Promise); + }); + + test('accepts type', () => { + const body = { type: 'index-pattern', invalid: true }; + + savedObjectsClient.find(body); + sinon.assert.calledOnce(kfetchStub); + sinon.assert.calledWithExactly( + kfetchStub, + sinon.match({ + pathname: `/api/saved_objects/_find`, + query: { type: 'index-pattern', invalid: true }, + }) + ); + }); + + test('accepts fields', () => { + const body = { fields: ['title', 'description'] }; + + savedObjectsClient.find(body); + sinon.assert.calledOnce(kfetchStub); + sinon.assert.calledWithExactly( + kfetchStub, + sinon.match({ + pathname: `/api/saved_objects/_find`, + query: { fields: ['title', 'description'] }, + }) + ); + }); + + test('accepts pagination params', () => { + const options: FindOptions = { perPage: 10, page: 6 }; + + savedObjectsClient.find(options); + sinon.assert.calledOnce(kfetchStub); + sinon.assert.alwaysCalledWith( + kfetchStub, + sinon.match({ + pathname: `/api/saved_objects/_find`, + query: { per_page: 10, page: 6 }, + }) + ); + }); + }); +}); diff --git a/src/ui/public/saved_objects/saved_objects_client.ts b/src/ui/public/saved_objects/saved_objects_client.ts new file mode 100644 index 000000000000..a048efe7f42b --- /dev/null +++ b/src/ui/public/saved_objects/saved_objects_client.ts @@ -0,0 +1,358 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { cloneDeep, pick, throttle } from 'lodash'; +import { resolve as resolveUrl } from 'url'; + +import { + MigrationVersion, + SavedObject as PlainSavedObject, + SavedObjectAttributes, + SavedObjectReference, + SavedObjectsClient as SavedObjectsApi, +} from '../../../../src/server/saved_objects'; +import { + CreateResponse, + FindOptions, + UpdateResponse, +} from '../../../../src/server/saved_objects/service'; +import { isAutoCreateIndexError, showAutoCreateIndexErrorPage } from '../error_auto_create_index'; +import { kfetch, KFetchQuery } from '../kfetch'; +import { keysToCamelCaseShallow, keysToSnakeCaseShallow } from '../utils/case_conversion'; +import { SavedObject } from './saved_object'; + +interface RequestParams { + method: 'POST' | 'GET' | 'PUT' | 'DELETE'; + path: string; + query?: KFetchQuery; + body?: object; +} + +interface CreateOptions { + id?: string; + overwrite?: boolean; + migrationVersion?: MigrationVersion; + references?: SavedObjectReference[]; +} + +interface BulkCreateOptions + extends CreateOptions { + type: string; + attributes: T; +} + +interface UpdateOptions { + version?: string; + migrationVersion?: MigrationVersion; + references?: SavedObjectReference[]; +} + +interface BatchResponse { + savedObjects: Array>; +} + +interface FindResults + extends BatchResponse { + total: number; + perPage: number; + page: number; +} + +interface BatchQueueEntry { + type: string; + id: string; + resolve: (value: SavedObject | PlainSavedObject) => void; + reject: (reason?: any) => void; +} + +const join = (...uriComponents: Array) => + uriComponents + .filter((comp): comp is string => Boolean(comp)) + .map(encodeURIComponent) + .join('/'); + +/** + * Interval that requests are batched for + * @type {integer} + */ +const BATCH_INTERVAL = 100; + +const API_BASE_URL = '/api/saved_objects/'; + +export class SavedObjectsClient { + /** + * Throttled processing of get requests into bulk requests at 100ms interval + */ + private processBatchQueue = throttle( + () => { + const queue = cloneDeep(this.batchQueue); + this.batchQueue = []; + + this.bulkGet(queue) + .then(({ savedObjects }) => { + queue.forEach(queueItem => { + const foundObject = savedObjects.find(savedObject => { + return savedObject.id === queueItem.id && savedObject.type === queueItem.type; + }); + + if (!foundObject) { + return queueItem.resolve(this.createSavedObject(pick(queueItem, ['id', 'type']))); + } + + queueItem.resolve(foundObject); + }); + }) + .catch(err => { + queue.forEach(queueItem => { + queueItem.reject(err); + }); + }); + }, + BATCH_INTERVAL, + { leading: false } + ); + + private batchQueue: BatchQueueEntry[]; + + constructor() { + this.batchQueue = []; + } + + /** + * 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] + * @property {object} [options.migrationVersion] + * @returns + */ + public create = ( + type: string, + attributes: T, + options: CreateOptions = {} + ): Promise> => { + if (!type || !attributes) { + return Promise.reject(new Error('requires type and attributes')); + } + + const path = this.getPath([type, options.id]); + const query = { + overwrite: options.overwrite, + }; + + const createRequest: Promise> = this.request({ + method: 'POST', + path, + query, + body: { + attributes, + migrationVersion: options.migrationVersion, + references: options.references, + }, + }); + + return createRequest + .then(resp => this.createSavedObject(resp)) + .catch((error: object) => { + if (isAutoCreateIndexError(error)) { + showAutoCreateIndexErrorPage(); + } + + throw error; + }); + }; + + /** + * Creates multiple documents at once + * + * @param {array} objects - [{ type, id, attributes, references, migrationVersion }] + * @param {object} [options={}] + * @property {boolean} [options.overwrite=false] + * @returns The result of the create operation containing created saved objects. + */ + public bulkCreate = (objects: BulkCreateOptions[] = [], options: KFetchQuery = {}) => { + const path = this.getPath(['_bulk_create']); + const query = pick(options, ['overwrite']) as Pick; + + const request: ReturnType = this.request({ + method: 'POST', + path, + query, + body: objects, + }); + return request.then(resp => { + resp.saved_objects = resp.saved_objects.map(d => this.createSavedObject(d)); + return keysToCamelCaseShallow(resp) as BatchResponse; + }); + }; + + /** + * Deletes an object + * + * @param type + * @param id + * @returns + */ + public delete = (type: string, id: string): ReturnType => { + if (!type || !id) { + return Promise.reject(new Error('requires type and id')); + } + + return this.request({ method: 'DELETE', path: this.getPath([type, id]) }); + }; + + /** + * Search for objects + * + * @param {object} [options={}] + * @property {string} options.type + * @property {string} options.search + * @property {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 {array} options.fields + * @property {object} [options.hasReference] - { type, id } + * @returns A find result with objects matching the specified search. + */ + public find = ( + options: FindOptions = {} + ): Promise> => { + const path = this.getPath(['_find']); + const query = keysToSnakeCaseShallow(options); + + const request: ReturnType = this.request({ + method: 'GET', + path, + query, + }); + return request.then(resp => { + resp.saved_objects = resp.saved_objects.map(d => this.createSavedObject(d)); + return keysToCamelCaseShallow(resp) as FindResults; + }); + }; + + /** + * Fetches a single object + * + * @param {string} type + * @param {string} id + * @returns The saved object for the given type and id. + */ + public get = ( + type: string, + id: string + ): Promise> => { + if (!type || !id) { + return Promise.reject(new Error('requires type and id')); + } + + return new Promise((resolve, reject) => { + this.batchQueue.push({ type, id, resolve, reject } as BatchQueueEntry); + this.processBatchQueue(); + }); + }; + + /** + * Returns an array of objects by id + * + * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @returns The saved objects with the given type and ids requested + * @example + * + * bulkGet([ + * { id: 'one', type: 'config' }, + * { id: 'foo', type: 'index-pattern' } + * ]) + */ + public bulkGet = (objects: Array<{ id: string; type: string }> = []) => { + const path = this.getPath(['_bulk_get']); + const filteredObjects = objects.map(obj => pick(obj, ['id', 'type'])); + + const request: ReturnType = this.request({ + method: 'POST', + path, + body: filteredObjects, + }); + return request.then(resp => { + resp.saved_objects = resp.saved_objects.map(d => this.createSavedObject(d)); + return keysToCamelCaseShallow(resp) as BatchResponse; + }); + }; + + /** + * Updates an object + * + * @param {string} type + * @param {string} id + * @param {object} attributes + * @param {object} options + * @prop {integer} options.version - ensures version matches that of persisted object + * @prop {object} options.migrationVersion - The optional migrationVersion of this document + * @returns + */ + public update( + type: string, + id: string, + attributes: T, + { version, migrationVersion, references }: UpdateOptions = {} + ): Promise> { + if (!type || !id || !attributes) { + return Promise.reject(new Error('requires type, id and attributes')); + } + + const path = this.getPath([type, id]); + const body = { + attributes, + migrationVersion, + references, + version, + }; + + const request: Promise> = this.request({ + method: 'PUT', + path, + body, + }); + return request.then(resp => { + return this.createSavedObject(resp); + }); + } + + private createSavedObject( + options: PlainSavedObject + ): SavedObject { + return new SavedObject(this, options); + } + + private getPath(path: Array): string { + return resolveUrl(API_BASE_URL, join(...path)); + } + + private request({ method, path, query, body }: RequestParams) { + if (method === 'GET' && body) { + return Promise.reject(new Error('body not permitted for GET requests')); + } + + return kfetch({ method, pathname: path, query, body: JSON.stringify(body) }); + } +} diff --git a/src/ui/public/saved_objects/saved_objects_client_provider.js b/src/ui/public/saved_objects/saved_objects_client_provider.ts similarity index 70% rename from src/ui/public/saved_objects/saved_objects_client_provider.js rename to src/ui/public/saved_objects/saved_objects_client_provider.ts index ee57dba06fb2..1ef0d07135fd 100644 --- a/src/ui/public/saved_objects/saved_objects_client_provider.js +++ b/src/ui/public/saved_objects/saved_objects_client_provider.ts @@ -18,33 +18,37 @@ */ import chrome from '../chrome'; +import { PromiseService } from '../promises'; +import { SavedObjectsClient } from './saved_objects_client'; + +type Args any> = T extends (...args: infer X) => any ? X : never; // Provide an angular wrapper around savedObjectClient so all actions get resolved in an Angular Promise // If you do not need the promise to execute in an angular digest cycle then you should not use this // and get savedObjectClient directly from chrome. -export function SavedObjectsClientProvider(Promise) { +export function SavedObjectsClientProvider(Promise: PromiseService) { const savedObjectsClient = chrome.getSavedObjectsClient(); return { - create: (...args) => { + create: (...args: Args) => { return Promise.resolve(savedObjectsClient.create(...args)); }, - bulkCreate: (...args) => { + bulkCreate: (...args: Args) => { return Promise.resolve(savedObjectsClient.bulkCreate(...args)); }, - delete: (...args) => { + delete: (...args: Args) => { return Promise.resolve(savedObjectsClient.delete(...args)); }, - find: (...args) => { + find: (...args: Args) => { return Promise.resolve(savedObjectsClient.find(...args)); }, - get: (...args) => { + get: (...args: Args) => { return Promise.resolve(savedObjectsClient.get(...args)); }, - bulkGet: (...args) => { + bulkGet: (...args: Args) => { return Promise.resolve(savedObjectsClient.bulkGet(...args)); }, - update: (...args) => { + update: (...args: Args) => { return Promise.resolve(savedObjectsClient.update(...args)); }, }; diff --git a/src/ui/public/utils/case_conversion.ts b/src/ui/public/utils/case_conversion.ts new file mode 100644 index 000000000000..fe2cb8aa209e --- /dev/null +++ b/src/ui/public/utils/case_conversion.ts @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// TODO: This file is copied from src/utils/case_conversion.ts +// because TS-imports from utils in ui are currently not possible. +// When the build process is updated, this file can be removed + +import _ from 'lodash'; + +export function keysToSnakeCaseShallow(object: Record) { + return _.mapKeys(object, (value, key) => { + return _.snakeCase(key); + }); +} + +export function keysToCamelCaseShallow(object: Record) { + return _.mapKeys(object, (value, key) => { + return _.camelCase(key); + }); +} diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 063dcff8a9fa..769851f13174 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "include": [ + "typings/**/*", "common/**/*", "server/**/*", "plugins/**/*", diff --git a/x-pack/typings/index.d.ts b/x-pack/typings/index.d.ts new file mode 100644 index 000000000000..38dbf632e578 --- /dev/null +++ b/x-pack/typings/index.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +declare module '*.html' { + const template: string; + export default template; +}