diff --git a/renovate.json5 b/renovate.json5 index 642c4a98b5799..58a64a5d0f967 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -913,6 +913,14 @@ '@types/tslib', ], }, + { + groupSlug: 'use-resize-observer', + groupName: 'use-resize-observer related packages', + packageNames: [ + 'use-resize-observer', + '@types/use-resize-observer', + ], + }, { groupSlug: 'uuid', groupName: 'uuid related packages', diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index ffdc04d156ca0..49c4d690c6876 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -55,13 +55,14 @@ const defaultContext: CoreContext = { configService, }; +export const createCoreContext = (overrides: Partial = {}): CoreContext => ({ + ...defaultContext, + ...overrides, +}); + /** * Creates a concrete HttpServer with a mocked context. */ export const createHttpServer = (overrides: Partial = {}): HttpService => { - const context = { - ...defaultContext, - ...overrides, - }; - return new HttpService(context); + return new HttpService(createCoreContext(overrides)); }; diff --git a/src/core/server/legacy/legacy_service.mock.ts b/src/core/server/legacy/legacy_service.mock.ts index 44405dc391d8e..26ec52185a5d8 100644 --- a/src/core/server/legacy/legacy_service.mock.ts +++ b/src/core/server/legacy/legacy_service.mock.ts @@ -29,6 +29,7 @@ const createDiscoverPluginsMock = (): LegacyServiceDiscoverPlugins => ({ savedObjectMappings: [], savedObjectMigrations: {}, savedObjectValidations: {}, + savedObjectsManagement: {}, }, navLinks: [], pluginExtendedConfig: { diff --git a/src/core/server/legacy/plugins/get_nav_links.test.ts b/src/core/server/legacy/plugins/get_nav_links.test.ts index dcb19020f769e..44d080ec37a25 100644 --- a/src/core/server/legacy/plugins/get_nav_links.test.ts +++ b/src/core/server/legacy/plugins/get_nav_links.test.ts @@ -35,6 +35,7 @@ const createLegacyExports = ({ savedObjectSchemas: {}, savedObjectMigrations: {}, savedObjectValidations: {}, + savedObjectsManagement: {}, }); const createPluginSpecs = (...ids: string[]): LegacyPluginSpec[] => diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 9a7868d568ea0..d6554babab53e 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -25,6 +25,7 @@ import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service. import { httpServiceMock } from './http/http_service.mock'; import { contextServiceMock } from './context/context_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; +import { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; import { InternalCoreSetup, InternalCoreStart } from './internal_types'; @@ -36,7 +37,6 @@ export { configServiceMock } from './config/config_service.mock'; export { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; export { httpServiceMock } from './http/http_service.mock'; export { loggingServiceMock } from './logging/logging_service.mock'; -export { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock'; export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; @@ -168,10 +168,31 @@ function createInternalCoreStartMock() { return startDeps; } +function createCoreRequestHandlerContextMock() { + return { + rendering: { + render: jest.fn(), + }, + savedObjects: { + client: savedObjectsClientMock.create(), + }, + elasticsearch: { + adminClient: elasticsearchServiceMock.createScopedClusterClient(), + dataClient: elasticsearchServiceMock.createScopedClusterClient(), + }, + uiSettings: { + client: uiSettingsServiceMock.createClient(), + }, + }; +} + export const coreMock = { createSetup: createCoreSetupMock, createStart: createCoreStartMock, createInternalSetup: createInternalCoreSetupMock, createInternalStart: createInternalCoreStartMock, createPluginInitializerContext: pluginInitializerContextMock, + createRequestHandlerContext: createCoreRequestHandlerContextMock, }; + +export { savedObjectsClientMock }; diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index fafa04447ddfe..1088478add137 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -492,7 +492,6 @@ describe('getSortedObjectsForExport()', () => { const exportStream = await getSortedObjectsForExport({ exportSizeLimit: 10000, savedObjectsClient, - types: ['index-pattern', 'search'], objects: [ { type: 'index-pattern', @@ -591,7 +590,6 @@ describe('getSortedObjectsForExport()', () => { const exportStream = await getSortedObjectsForExport({ exportSizeLimit: 10000, savedObjectsClient, - types: ['index-pattern', 'search'], objects: [ { type: 'search', @@ -672,7 +670,6 @@ describe('getSortedObjectsForExport()', () => { const exportOpts = { exportSizeLimit: 1, savedObjectsClient, - types: ['index-pattern', 'search'], objects: [ { type: 'index-pattern', diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index a4dfacfd9e34f..4b4cf1146aca0 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -84,6 +84,9 @@ async function fetchObjectsToExport({ savedObjectsClient: SavedObjectsClientContract; namespace?: string; }) { + if ((types?.length ?? 0) > 0 && (objects?.length ?? 0) > 0) { + throw Boom.badRequest(`Can't specify both "types" and "objects" properties when exporting`); + } if (objects && objects.length > 0) { if (objects.length > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 529ee9599f178..5be4458bdf2af 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -68,5 +68,5 @@ export { SavedObjectMigrationMap, SavedObjectMigrationFn } from './migrations'; export { SavedObjectsType } from './types'; -export { config } from './saved_objects_config'; +export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 9b4fe10a35100..494f834717def 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -30,14 +30,14 @@ import { docValidator, PropertyValidators } from '../../validation'; import { buildActiveMappings, CallCluster, IndexMigrator } from '../core'; import { DocumentMigrator, VersionedTransformer } from '../core/document_migrator'; import { createIndexMap } from '../core/build_index_map'; -import { SavedObjectsConfigType } from '../../saved_objects_config'; +import { SavedObjectsMigrationConfigType } from '../../saved_objects_config'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; export interface KibanaMigratorOptions { callCluster: CallCluster; typeRegistry: ISavedObjectTypeRegistry; - savedObjectsConfig: SavedObjectsConfigType; + savedObjectsConfig: SavedObjectsMigrationConfigType; kibanaConfig: KibanaConfigType; kibanaVersion: string; logger: Logger; @@ -51,7 +51,7 @@ export type IKibanaMigrator = Pick; */ export class KibanaMigrator { private readonly callCluster: CallCluster; - private readonly savedObjectsConfig: SavedObjectsConfigType; + private readonly savedObjectsConfig: SavedObjectsMigrationConfigType; private readonly documentMigrator: VersionedTransformer; private readonly kibanaConfig: KibanaConfigType; private readonly log: Logger; diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts new file mode 100644 index 0000000000000..af1a7bd2af9b7 --- /dev/null +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -0,0 +1,57 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerBulkCreateRoute = (router: IRouter) => { + router.post( + { + path: '/_bulk_create', + validate: { + query: schema.object({ + overwrite: schema.boolean({ defaultValue: false }), + }), + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.maybe(schema.string()), + attributes: schema.recordOf(schema.string(), schema.any()), + version: schema.maybe(schema.string()), + migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), + references: schema.maybe( + schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }) + ) + ), + }) + ), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { overwrite } = req.query; + const result = await context.core.savedObjects.client.bulkCreate(req.body, { overwrite }); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/legacy/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/bulk_get.ts similarity index 55% rename from src/legacy/server/saved_objects/routes/index.ts rename to src/core/server/saved_objects/routes/bulk_get.ts index 0afcfba308546..067388dcf9220 100644 --- a/src/legacy/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/bulk_get.ts @@ -17,15 +17,26 @@ * under the License. */ -export { createBulkCreateRoute } from './bulk_create'; -export { createBulkGetRoute } from './bulk_get'; -export { createCreateRoute } from './create'; -export { createDeleteRoute } from './delete'; -export { createFindRoute } from './find'; -export { createGetRoute } from './get'; -export { createImportRoute } from './import'; -export { createLogLegacyImportRoute } from './log_legacy_import'; -export { createResolveImportErrorsRoute } from './resolve_import_errors'; -export { createUpdateRoute } from './update'; -export { createBulkUpdateRoute } from './bulk_update'; -export { createExportRoute } from './export'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerBulkGetRoute = (router: IRouter) => { + router.post( + { + path: '/_bulk_get', + validate: { + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + fields: schema.maybe(schema.arrayOf(schema.string())), + }) + ), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const result = await context.core.savedObjects.client.bulkGet(req.body); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/bulk_update.ts b/src/core/server/saved_objects/routes/bulk_update.ts new file mode 100644 index 0000000000000..c112833b29f3f --- /dev/null +++ b/src/core/server/saved_objects/routes/bulk_update.ts @@ -0,0 +1,52 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerBulkUpdateRoute = (router: IRouter) => { + router.put( + { + path: '/_bulk_update', + validate: { + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + attributes: schema.recordOf(schema.string(), schema.any()), + version: schema.maybe(schema.string()), + references: schema.maybe( + schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }) + ) + ), + }) + ), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObject = await context.core.savedObjects.client.bulkUpdate(req.body); + return res.ok({ body: savedObject }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts new file mode 100644 index 0000000000000..6cf906a3b2895 --- /dev/null +++ b/src/core/server/saved_objects/routes/create.ts @@ -0,0 +1,60 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerCreateRoute = (router: IRouter) => { + router.post( + { + path: '/{type}/{id?}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.maybe(schema.string()), + }), + query: schema.object({ + overwrite: schema.boolean({ defaultValue: false }), + }), + body: schema.object({ + attributes: schema.recordOf(schema.string(), schema.any()), + migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), + references: schema.maybe( + schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }) + ) + ), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const { overwrite } = req.query; + const { attributes, migrationVersion, references } = req.body; + + const options = { id, overwrite, migrationVersion, references }; + const result = await context.core.savedObjects.client.create(type, attributes, options); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/legacy/server/saved_objects/routes/types.ts b/src/core/server/saved_objects/routes/delete.ts similarity index 58% rename from src/legacy/server/saved_objects/routes/types.ts rename to src/core/server/saved_objects/routes/delete.ts index b3f294b66499b..d119455336212 100644 --- a/src/legacy/server/saved_objects/routes/types.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -17,20 +17,24 @@ * under the License. */ -import Hapi from 'hapi'; -import { SavedObjectsClientContract } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; -export interface SavedObjectReference { - name: string; - type: string; - id: string; -} - -export interface Prerequisites { - getSavedObjectsClient: { - assign: string; - method: (req: Hapi.Request) => SavedObjectsClientContract; - }; -} - -export type WithoutQueryAndParams = Pick>; +export const registerDeleteRoute = (router: IRouter) => { + router.delete( + { + path: '/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const result = await context.core.savedObjects.client.delete(type, id); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts new file mode 100644 index 0000000000000..ab287332d8a65 --- /dev/null +++ b/src/core/server/saved_objects/routes/export.ts @@ -0,0 +1,97 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import stringify from 'json-stable-stringify'; +import { + createPromiseFromStreams, + createMapStream, + createConcatStream, +} from '../../../../legacy/utils/streams'; +import { IRouter } from '../../http'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { getSortedObjectsForExport } from '../export'; + +export const registerExportRoute = ( + router: IRouter, + config: SavedObjectConfig, + supportedTypes: string[] +) => { + const { maxImportExportSize } = config; + + const typeSchema = schema.string({ + validate: (type: string) => { + if (!supportedTypes.includes(type)) { + return `${type} is not exportable`; + } + }, + }); + + router.post( + { + path: '/_export', + validate: { + body: schema.object({ + type: schema.maybe(schema.oneOf([typeSchema, schema.arrayOf(typeSchema)])), + objects: schema.maybe( + schema.arrayOf( + schema.object({ + type: typeSchema, + id: schema.string(), + }), + { maxSize: maxImportExportSize } + ) + ), + search: schema.maybe(schema.string()), + includeReferencesDeep: schema.boolean({ defaultValue: false }), + excludeExportDetails: schema.boolean({ defaultValue: false }), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObjectsClient = context.core.savedObjects.client; + const { type, objects, search, excludeExportDetails, includeReferencesDeep } = req.body; + const exportStream = await getSortedObjectsForExport({ + savedObjectsClient, + types: typeof type === 'string' ? [type] : type, + search, + objects, + exportSizeLimit: maxImportExportSize, + includeReferencesDeep, + excludeExportDetails, + }); + + const docsToExport: string[] = await createPromiseFromStreams([ + exportStream, + createMapStream((obj: unknown) => { + return stringify(obj); + }), + createConcatStream([]), + ]); + + return res.ok({ + body: docsToExport.join('\n'), + headers: { + 'Content-Disposition': `attachment; filename="export.ndjson"`, + 'Content-Type': 'application/ndjson', + }, + }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts new file mode 100644 index 0000000000000..5c1c2c9a9ab87 --- /dev/null +++ b/src/core/server/saved_objects/routes/find.ts @@ -0,0 +1,70 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerFindRoute = (router: IRouter) => { + router.get( + { + path: '/_find', + validate: { + query: schema.object({ + per_page: schema.number({ min: 0, defaultValue: 20 }), + page: schema.number({ min: 0, defaultValue: 1 }), + type: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + search: schema.maybe(schema.string()), + default_search_operator: schema.oneOf([schema.literal('OR'), schema.literal('AND')], { + defaultValue: 'OR', + }), + search_fields: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), + sort_field: schema.maybe(schema.string()), + has_reference: schema.maybe( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + filter: schema.maybe(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const query = req.query; + const result = await context.core.savedObjects.client.find({ + perPage: query.per_page, + page: query.page, + type: Array.isArray(query.type) ? query.type : [query.type], + search: query.search, + defaultSearchOperator: query.default_search_operator, + searchFields: + typeof query.search_fields === 'string' ? [query.search_fields] : query.search_fields, + sortField: query.sort_field, + hasReference: query.has_reference, + fields: typeof query.fields === 'string' ? [query.fields] : query.fields, + filter: query.filter, + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/legacy/server/saved_objects/lib/index.ts b/src/core/server/saved_objects/routes/get.ts similarity index 58% rename from src/legacy/server/saved_objects/lib/index.ts rename to src/core/server/saved_objects/routes/get.ts index 1255ef67a03c2..f1b974c70b1a9 100644 --- a/src/legacy/server/saved_objects/lib/index.ts +++ b/src/core/server/saved_objects/routes/get.ts @@ -17,4 +17,24 @@ * under the License. */ -export { createSavedObjectsStreamFromNdJson } from './create_saved_objects_stream_from_ndjson'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerGetRoute = (router: IRouter) => { + router.get( + { + path: '/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const savedObject = await context.core.savedObjects.client.get(type, id); + return res.ok({ body: savedObject }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts new file mode 100644 index 0000000000000..e3f249dca05f7 --- /dev/null +++ b/src/core/server/saved_objects/routes/import.ts @@ -0,0 +1,79 @@ +/* + * 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 { Readable } from 'stream'; +import { extname } from 'path'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; +import { importSavedObjects } from '../import'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { createSavedObjectsStreamFromNdJson } from './utils'; + +interface FileStream extends Readable { + hapi: { + filename: string; + }; +} + +export const registerImportRoute = ( + router: IRouter, + config: SavedObjectConfig, + supportedTypes: string[] +) => { + const { maxImportExportSize, maxImportPayloadBytes } = config; + + router.post( + { + path: '/_import', + options: { + body: { + maxBytes: maxImportPayloadBytes, + output: 'stream', + accepts: 'multipart/form-data', + }, + }, + validate: { + query: schema.object({ + overwrite: schema.boolean({ defaultValue: false }), + }), + body: schema.object({ + file: schema.stream(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { overwrite } = req.query; + const file = req.body.file as FileStream; + const fileExtension = extname(file.hapi.filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); + } + + const result = await importSavedObjects({ + supportedTypes, + savedObjectsClient: context.core.savedObjects.client, + readStream: createSavedObjectsStreamFromNdJson(file), + objectLimit: maxImportExportSize, + overwrite, + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts new file mode 100644 index 0000000000000..f2f57798dd5f0 --- /dev/null +++ b/src/core/server/saved_objects/routes/index.ts @@ -0,0 +1,61 @@ +/* + * 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 { InternalHttpServiceSetup } from '../../http'; +import { Logger } from '../../logging'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { registerGetRoute } from './get'; +import { registerCreateRoute } from './create'; +import { registerDeleteRoute } from './delete'; +import { registerFindRoute } from './find'; +import { registerUpdateRoute } from './update'; +import { registerBulkGetRoute } from './bulk_get'; +import { registerBulkCreateRoute } from './bulk_create'; +import { registerBulkUpdateRoute } from './bulk_update'; +import { registerLogLegacyImportRoute } from './log_legacy_import'; +import { registerExportRoute } from './export'; +import { registerImportRoute } from './import'; +import { registerResolveImportErrorsRoute } from './resolve_import_errors'; + +export function registerRoutes({ + http, + logger, + config, + importableExportableTypes, +}: { + http: InternalHttpServiceSetup; + logger: Logger; + config: SavedObjectConfig; + importableExportableTypes: string[]; +}) { + const router = http.createRouter('/api/saved_objects/'); + + registerGetRoute(router); + registerCreateRoute(router); + registerDeleteRoute(router); + registerFindRoute(router); + registerUpdateRoute(router); + registerBulkGetRoute(router); + registerBulkCreateRoute(router); + registerBulkUpdateRoute(router); + registerLogLegacyImportRoute(router, logger); + registerExportRoute(router, config, importableExportableTypes); + registerImportRoute(router, config, importableExportableTypes); + registerResolveImportErrorsRoute(router, config, importableExportableTypes); +} diff --git a/src/legacy/server/saved_objects/routes/bulk_create.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts similarity index 55% rename from src/legacy/server/saved_objects/routes/bulk_create.test.ts rename to src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts index b49554995aab6..5b52665b6268e 100644 --- a/src/legacy/server/saved_objects/routes/bulk_create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts @@ -17,52 +17,36 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createBulkCreateRoute } from './bulk_create'; -// Disable lint errors for imports from src/core/* until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { savedObjectsClientMock } from '../../../../core/server/saved_objects/service/saved_objects_client.mock'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerBulkCreateRoute } from '../bulk_create'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('POST /api/saved_objects/_bulk_create', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { - savedObjectsClient.bulkCreate.mockImplementation(() => Promise.resolve('' as any)); - server = createMockServer(); + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: [] }); - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + const router = httpSetup.createRouter('/api/saved_objects/'); + registerBulkCreateRoute(router); - server.route(createBulkCreateRoute(prereqs)); + await server.start(); }); - afterEach(() => { - savedObjectsClient.bulkCreate.mockReset(); + afterEach(async () => { + await server.stop(); }); it('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_bulk_create', - payload: [ - { - id: 'abc123', - type: 'index-pattern', - attributes: { - title: 'my_title', - }, - }, - ], - }; - const clientResponse = { saved_objects: [ { @@ -75,14 +59,22 @@ describe('POST /api/saved_objects/_bulk_create', () => { }, ], }; + savedObjectsClient.bulkCreate.mockResolvedValue(clientResponse); - savedObjectsClient.bulkCreate.mockImplementation(() => Promise.resolve(clientResponse)); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_create') + .send([ + { + id: 'abc123', + type: 'index-pattern', + attributes: { + title: 'my_title', + }, + }, + ]) + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); + expect(result.body).toEqual(clientResponse); }); it('calls upon savedObjectClient.bulkCreate', async () => { @@ -105,24 +97,20 @@ describe('POST /api/saved_objects/_bulk_create', () => { }, ]; - const request = { - method: 'POST', - url: '/api/saved_objects/_bulk_create', - payload: docs, - }; - - await server.inject(request); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalled(); + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_create') + .send(docs) + .expect(200); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); const args = savedObjectsClient.bulkCreate.mock.calls[0]; expect(args[0]).toEqual(docs); }); it('passes along the overwrite option', async () => { - await server.inject({ - method: 'POST', - url: '/api/saved_objects/_bulk_create?overwrite=true', - payload: [ + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_create?overwrite=true') + .send([ { id: 'abc1234', type: 'index-pattern', @@ -131,11 +119,10 @@ describe('POST /api/saved_objects/_bulk_create', () => { }, references: [], }, - ], - }); - - expect(savedObjectsClient.bulkCreate).toHaveBeenCalled(); + ]) + .expect(200); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); const args = savedObjectsClient.bulkCreate.mock.calls[0]; expect(args[1]).toEqual({ overwrite: true }); }); diff --git a/src/legacy/server/saved_objects/routes/bulk_get.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts similarity index 53% rename from src/legacy/server/saved_objects/routes/bulk_get.test.ts rename to src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts index e154649e2cf04..845bae47b41f2 100644 --- a/src/legacy/server/saved_objects/routes/bulk_get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts @@ -17,50 +17,38 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createBulkGetRoute } from './bulk_get'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerBulkGetRoute } from '../bulk_get'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('POST /api/saved_objects/_bulk_get', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { - savedObjectsClient.bulkGet.mockImplementation(() => - Promise.resolve({ - saved_objects: [], - }) - ); - server = createMockServer(); - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [], + }); + const router = httpSetup.createRouter('/api/saved_objects/'); + registerBulkGetRoute(router); - server.route(createBulkGetRoute(prereqs)); + await server.start(); }); - afterEach(() => { - savedObjectsClient.bulkGet.mockReset(); + afterEach(async () => { + await server.stop(); }); it('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_bulk_get', - payload: [ - { - id: 'abc123', - type: 'index-pattern', - }, - ], - }; - const clientResponse = { saved_objects: [ { @@ -73,14 +61,19 @@ describe('POST /api/saved_objects/_bulk_get', () => { }, ], }; - savedObjectsClient.bulkGet.mockImplementation(() => Promise.resolve(clientResponse)); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_get') + .send([ + { + id: 'abc123', + type: 'index-pattern', + }, + ]) + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); + expect(result.body).toEqual(clientResponse); }); it('calls upon savedObjectClient.bulkGet', async () => { @@ -91,14 +84,12 @@ describe('POST /api/saved_objects/_bulk_get', () => { }, ]; - const request = { - method: 'POST', - url: '/api/saved_objects/_bulk_get', - payload: docs, - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_get') + .send(docs) + .expect(200); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(docs); }); }); diff --git a/src/legacy/server/saved_objects/routes/bulk_update.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts similarity index 65% rename from src/legacy/server/saved_objects/routes/bulk_update.test.ts rename to src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts index dc21ab08035ce..6356fc787a8d8 100644 --- a/src/legacy/server/saved_objects/routes/bulk_update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts @@ -17,56 +17,35 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createBulkUpdateRoute } from './bulk_update'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerBulkUpdateRoute } from '../bulk_update'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('PUT /api/saved_objects/_bulk_update', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { - server = createMockServer(); + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + const router = httpSetup.createRouter('/api/saved_objects/'); + registerBulkUpdateRoute(router); - server.route(createBulkUpdateRoute(prereqs)); + await server.start(); }); - afterEach(() => { - savedObjectsClient.bulkUpdate.mockReset(); + afterEach(async () => { + await server.stop(); }); it('formats successful response', async () => { - const request = { - method: 'PUT', - url: '/api/saved_objects/_bulk_update', - payload: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - attributes: { - title: 'An existing visualization', - }, - }, - { - type: 'dashboard', - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - attributes: { - title: 'An existing dashboard', - }, - }, - ], - }; - const time = Date.now().toLocaleString(); const clientResponse = [ { @@ -90,23 +69,37 @@ describe('PUT /api/saved_objects/_bulk_update', () => { }, }, ]; + savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: clientResponse }); - savedObjectsClient.bulkUpdate.mockImplementation(() => - Promise.resolve({ saved_objects: clientResponse }) - ); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .put('/api/saved_objects/_bulk_update') + .send([ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + attributes: { + title: 'An existing visualization', + }, + }, + { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + attributes: { + title: 'An existing dashboard', + }, + }, + ]) + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual({ saved_objects: clientResponse }); + expect(result.body).toEqual({ saved_objects: clientResponse }); }); it('calls upon savedObjectClient.bulkUpdate', async () => { - const request = { - method: 'PUT', - url: '/api/saved_objects/_bulk_update', - payload: [ + savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: [] }); + + await supertest(httpSetup.server.listener) + .put('/api/saved_objects/_bulk_update') + .send([ { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -121,13 +114,10 @@ describe('PUT /api/saved_objects/_bulk_update', () => { title: 'An existing dashboard', }, }, - ], - }; - - savedObjectsClient.bulkUpdate.mockImplementation(() => Promise.resolve({ saved_objects: [] })); - - await server.inject(request); + ]) + .expect(200); + expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledWith([ { type: 'visualization', diff --git a/src/core/server/saved_objects/routes/integration_tests/create.test.ts b/src/core/server/saved_objects/routes/integration_tests/create.test.ts new file mode 100644 index 0000000000000..5a53a30209281 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/create.test.ts @@ -0,0 +1,120 @@ +/* + * 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 supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerCreateRoute } from '../create'; +import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; + +describe('POST /api/saved_objects/{type}', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + const clientResponse = { + id: 'logstash-*', + type: 'index-pattern', + title: 'logstash-*', + version: 'foo', + references: [], + attributes: {}, + }; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.create.mockImplementation(() => Promise.resolve(clientResponse)); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerCreateRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/index-pattern') + .send({ + attributes: { + title: 'Testing', + }, + }) + .expect(200); + + expect(result.body).toEqual(clientResponse); + }); + + it('requires attributes', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/index-pattern') + .send({}) + .expect(400); + + // expect(response.validation.keys).toContain('attributes'); + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.attributes]: expected value of type [object] but got [undefined]"` + ); + }); + + it('calls upon savedObjectClient.create', async () => { + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/index-pattern') + .send({ + attributes: { + title: 'Testing', + }, + }) + .expect(200); + + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + 'index-pattern', + { title: 'Testing' }, + { overwrite: false, id: undefined, migrationVersion: undefined } + ); + }); + + it('can specify an id', async () => { + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/index-pattern/logstash-*') + .send({ + attributes: { + title: 'Testing', + }, + }) + .expect(200); + + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + + const args = savedObjectsClient.create.mock.calls[0]; + expect(args).toEqual([ + 'index-pattern', + { title: 'Testing' }, + { overwrite: false, id: 'logstash-*' }, + ]); + }); +}); diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts new file mode 100644 index 0000000000000..d4ce4d421dde1 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -0,0 +1,63 @@ +/* + * 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 supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerDeleteRoute } from '../delete'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; + +describe('DELETE /api/saved_objects/{type}/{id}', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerDeleteRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .delete('/api/saved_objects/index-pattern/logstash-*') + .expect(200); + + expect(result.body).toEqual({}); + }); + + it('calls upon savedObjectClient.delete', async () => { + await supertest(httpSetup.server.listener) + .delete('/api/saved_objects/index-pattern/logstash-*') + .expect(200); + + expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*'); + }); +}); diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts new file mode 100644 index 0000000000000..b52a8957176cc --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -0,0 +1,110 @@ +/* + * 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('../../export', () => ({ + getSortedObjectsForExport: jest.fn(), +})); + +import * as exportMock from '../../export'; +import { createListStream } from '../../../../../legacy/utils/streams'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { SavedObjectConfig } from '../../saved_objects_config'; +import { registerExportRoute } from '../export'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; +const getSortedObjectsForExport = exportMock.getSortedObjectsForExport as jest.Mock; +const allowedTypes = ['index-pattern', 'search']; +const config = { + maxImportPayloadBytes: 10485760, + maxImportExportSize: 10000, +} as SavedObjectConfig; + +describe('POST /api/saved_objects/_export', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer()); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerExportRoute(router, config, allowedTypes); + + await server.start(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await server.stop(); + }); + + it('formats successful response', async () => { + const sortedObjects = [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'search', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '1', + }, + ], + }, + ]; + getSortedObjectsForExport.mockResolvedValueOnce(createListStream(sortedObjects)); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_export') + .send({ + type: 'search', + search: 'my search string', + includeReferencesDeep: true, + }); + + expect(result.status).toBe(200); + expect(result.header).toEqual( + expect.objectContaining({ + 'content-disposition': 'attachment; filename="export.ndjson"', + 'content-type': 'application/ndjson', + }) + ); + + const objects = (result.text as string).split('\n').map(row => JSON.parse(row)); + expect(objects).toEqual(sortedObjects); + expect(getSortedObjectsForExport.mock.calls[0][0]).toEqual( + expect.objectContaining({ + excludeExportDetails: false, + exportSizeLimit: 10000, + includeReferencesDeep: true, + objects: undefined, + search: 'my search string', + types: ['search'], + }) + ); + }); +}); diff --git a/src/legacy/server/saved_objects/routes/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts similarity index 55% rename from src/legacy/server/saved_objects/routes/find.test.ts rename to src/core/server/saved_objects/routes/integration_tests/find.test.ts index 4bf5f57fec199..907bf44c7748f 100644 --- a/src/legacy/server/saved_objects/routes/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -17,14 +17,21 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createFindRoute } from './find'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import querystring from 'querystring'; + +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerFindRoute } from '../find'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('GET /api/saved_objects/_find', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; const clientResponse = { total: 0, @@ -32,48 +39,34 @@ describe('GET /api/saved_objects/_find', () => { per_page: 0, page: 0, }; - beforeEach(() => { - savedObjectsClient.find.mockImplementation(() => Promise.resolve(clientResponse)); - server = createMockServer(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - server.route(createFindRoute(prereqs)); + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + savedObjectsClient.find.mockResolvedValue(clientResponse); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerFindRoute(router); + + await server.start(); }); - afterEach(() => { - savedObjectsClient.find.mockReset(); + afterEach(async () => { + await server.stop(); }); it('returns with status 400 when type is missing', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find', - }; + const result = await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find') + .expect(400); - const { payload, statusCode } = await server.inject(request); - - expect(statusCode).toEqual(400); - expect(JSON.parse(payload)).toMatchObject({ - statusCode: 400, - error: 'Bad Request', - message: 'child "type" fails because ["type" is required]', - }); + expect(result.body.message).toContain( + '[request query.type]: expected at least one defined value' + ); }); it('formats successful response', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=index-pattern', - }; - const findResponse = { total: 2, per_page: 2, @@ -99,23 +92,19 @@ describe('GET /api/saved_objects/_find', () => { }, ], }; + savedObjectsClient.find.mockResolvedValue(findResponse); - savedObjectsClient.find.mockImplementation(() => Promise.resolve(findResponse)); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern') + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual(findResponse); + expect(result.body).toEqual(findResponse); }); it('calls upon savedObjectClient.find with defaults', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&type=bar', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&type=bar') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -129,12 +118,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter page/per_page', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&per_page=10&page=50', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&per_page=10&page=50') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -142,13 +128,41 @@ describe('GET /api/saved_objects/_find', () => { expect(options).toEqual({ perPage: 10, page: 50, type: ['foo'], defaultSearchOperator: 'OR' }); }); - it('accepts the query parameter search_fields', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&search_fields=title', - }; + it('accepts the optional query parameter has_reference', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options.hasReference).toBe(undefined); + }); + + it('accepts the query parameter has_reference', async () => { + const references = querystring.escape( + JSON.stringify({ + id: '1', + type: 'reference', + }) + ); + await supertest(httpSetup.server.listener) + .get(`/api/saved_objects/_find?type=foo&has_reference=${references}`) + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options.hasReference).toEqual({ + id: '1', + type: 'reference', + }); + }); - await server.inject(request); + it('accepts the query parameter search_fields', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&search_fields=title') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -163,12 +177,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter fields as a string', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&fields=title', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&fields=title') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -183,12 +194,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter fields as an array', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=foo&fields=title&fields=description', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=foo&fields=title&fields=description') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -203,12 +211,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter type as a string', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=index-pattern', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -222,12 +227,9 @@ describe('GET /api/saved_objects/_find', () => { }); it('accepts the query parameter type as an array', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/_find?type=index-pattern&type=visualization', - }; - - await server.inject(request); + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&type=visualization') + .expect(200); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); diff --git a/src/core/server/saved_objects/routes/integration_tests/get.test.ts b/src/core/server/saved_objects/routes/integration_tests/get.test.ts new file mode 100644 index 0000000000000..1e3405d7a318f --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/get.test.ts @@ -0,0 +1,92 @@ +/* + * 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 supertest from 'supertest'; +import { registerGetRoute } from '../get'; +import { ContextService } from '../../../context'; +import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; +import { HttpService, InternalHttpServiceSetup } from '../../../http'; +import { createHttpServer, createCoreContext } from '../../../http/test_utils'; +import { coreMock } from '../../../mocks'; + +const coreId = Symbol('core'); + +describe('GET /api/saved_objects/{type}/{id}', () => { + let server: HttpService; + let httpSetup: InternalHttpServiceSetup; + let handlerContext: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(async () => { + const coreContext = createCoreContext({ coreId }); + server = createHttpServer(coreContext); + + const contextService = new ContextService(coreContext); + httpSetup = await server.setup({ + context: contextService.setup({ pluginDependencies: new Map() }), + }); + + handlerContext = coreMock.createRequestHandlerContext(); + savedObjectsClient = handlerContext.savedObjects.client; + + httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { + return handlerContext; + }); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerGetRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const clientResponse = { + id: 'logstash-*', + title: 'logstash-*', + type: 'logstash-type', + attributes: {}, + timeFieldName: '@timestamp', + notExpandable: true, + references: [], + }; + + savedObjectsClient.get.mockResolvedValue(clientResponse); + + const result = await supertest(httpSetup.server.listener) + .get('/api/saved_objects/index-pattern/logstash-*') + .expect(200); + + expect(result.body).toEqual(clientResponse); + }); + + it('calls upon savedObjectClient.get', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/index-pattern/logstash-*') + .expect(200); + + expect(savedObjectsClient.get).toHaveBeenCalled(); + + const args = savedObjectsClient.get.mock.calls[0]; + expect(args).toEqual(['index-pattern', 'logstash-*']); + }); +}); diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts new file mode 100644 index 0000000000000..2c8d568b750c6 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -0,0 +1,320 @@ +/* + * 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 supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerImportRoute } from '../import'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { SavedObjectConfig } from '../../saved_objects_config'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; + +const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; +const config = { + maxImportPayloadBytes: 10485760, + maxImportExportSize: 10000, +} as SavedObjectConfig; + +describe('POST /api/saved_objects/_import', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + const emptyResponse = { + saved_objects: [], + total: 0, + per_page: 0, + page: 0, + }; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + savedObjectsClient.find.mockResolvedValue(emptyResponse); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerImportRoute(router, config, allowedTypes); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') + .send( + [ + '--BOUNDARY', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '', + '--BOUNDARY--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 0, + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); + }); + + it('defaults migrationVersion to empty object', async () => { + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + attributes: { + title: 'my-pattern-*', + }, + references: [], + }, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 1, + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; + expect(firstBulkCreateCallArray).toHaveLength(1); + expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); + }); + + it('imports an index pattern and dashboard, ignoring empty lines in the file', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + attributes: { + title: 'my-pattern-*', + }, + references: [], + }, + { + type: 'dashboard', + id: 'my-dashboard', + attributes: { + title: 'Look at my dashboard', + }, + references: [], + }, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '', + '', + '', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: true, + successCount: 2, + }); + }); + + it('imports an index pattern and dashboard but has a conflict on the index pattern', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + attributes: {}, + references: [], + error: { + statusCode: 409, + message: 'version conflict, document already exists', + }, + }, + { + type: 'dashboard', + id: 'my-dashboard', + attributes: { + title: 'Look at my dashboard', + }, + references: [], + }, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: false, + successCount: 1, + errors: [ + { + id: 'my-pattern', + type: 'index-pattern', + title: 'my-pattern-*', + error: { + type: 'conflict', + }, + }, + ], + }); + }); + + it('imports a visualization with missing references', async () => { + // NOTE: changes to this scenario should be reflected in the docs + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: 'my-pattern-*', + type: 'index-pattern', + error: { + statusCode: 404, + message: 'Not found', + }, + references: [], + attributes: {}, + }, + ], + }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_import') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ + success: false, + successCount: 0, + errors: [ + { + id: 'my-vis', + type: 'visualization', + title: 'my-vis', + error: { + type: 'missing_references', + references: [ + { + type: 'index-pattern', + id: 'my-pattern-*', + }, + ], + blocking: [ + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + }, + }, + ], + }); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "fields": Array [ + "id", + ], + "id": "my-pattern-*", + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); diff --git a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts new file mode 100644 index 0000000000000..4bbe3271e0232 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts @@ -0,0 +1,61 @@ +/* + * 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 supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerLogLegacyImportRoute } from '../log_legacy_import'; +import { loggingServiceMock } from '../../../logging/logging_service.mock'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; + +describe('POST /api/saved_objects/_log_legacy_import', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let logger: ReturnType; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer()); + logger = loggingServiceMock.createLogger(); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerLogLegacyImportRoute(router, logger); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('logs a warning when called', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_log_legacy_import') + .expect(200); + + expect(result.body).toEqual({ success: true }); + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + Array [ + Array [ + "Importing saved objects from a .json file has been deprecated", + ], + ] + `); + }); +}); diff --git a/src/legacy/server/saved_objects/routes/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts similarity index 51% rename from src/legacy/server/saved_objects/routes/resolve_import_errors.test.ts rename to src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index 44fa46bccfce5..c2974395217f8 100644 --- a/src/legacy/server/saved_objects/routes/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -17,84 +17,66 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createResolveImportErrorsRoute } from './resolve_import_errors'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; +import { SavedObjectConfig } from '../../saved_objects_config'; + +type setupServerReturn = UnwrapPromise>; + +const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; +const config = { + maxImportPayloadBytes: 10485760, + maxImportExportSize: 10000, +} as SavedObjectConfig; describe('POST /api/saved_objects/_resolve_import_errors', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { - server = createMockServer(); - jest.resetAllMocks(); + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + const router = httpSetup.createRouter('/api/saved_objects/'); + registerResolveImportErrorsRoute(router, config, allowedTypes); - server.route( - createResolveImportErrorsRoute(prereqs, server, [ - 'index-pattern', - 'visualization', - 'dashboard', - ]) - ); + await server.start(); }); - test('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--BOUNDARY', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '', - '--BOUNDARY', - 'Content-Disposition: form-data; name="retries"', - '', - '[]', - '--BOUNDARY--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=BOUNDARY', - }, - }; - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 0 }); + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=BOUNDARY') + .send( + [ + '--BOUNDARY', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '', + '--BOUNDARY', + 'Content-Disposition: form-data; name="retries"', + '', + '[]', + '--BOUNDARY--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 0 }); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); }); - test('defaults migrationVersion to empty object', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE', - 'Content-Disposition: form-data; name="retries"', - '', - '[{"type":"dashboard","id":"my-dashboard"}]', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; + it('defaults migrationVersion to empty object', async () => { savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -107,37 +89,35 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { }, ], }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 1 }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"dashboard","id":"my-dashboard"}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 1 }); expect(savedObjectsClient.bulkCreate.mock.calls).toHaveLength(1); const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; expect(firstBulkCreateCallArray).toHaveLength(1); expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); }); - test('retries importing a dashboard', async () => { + it('retries importing a dashboard', async () => { // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE', - 'Content-Disposition: form-data; name="retries"', - '', - '[{"type":"dashboard","id":"my-dashboard"}]', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -150,10 +130,27 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { }, ], }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 1 }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"dashboard","id":"my-dashboard"}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 1 }); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ @@ -183,28 +180,8 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { `); }); - test('resolves conflicts for dashboard', async () => { + it('resolves conflicts for dashboard', async () => { // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE', - 'Content-Disposition: form-data; name="retries"', - '', - '[{"type":"dashboard","id":"my-dashboard","overwrite":true}]', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -217,10 +194,28 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { }, ], }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 1 }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', + '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"dashboard","id":"my-dashboard","overwrite":true}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 1 }); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ @@ -251,27 +246,8 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { `); }); - test('resolves conflicts by replacing the visualization references', async () => { + it('resolves conflicts by replacing the visualization references', async () => { // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_resolve_import_errors', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"missing"}]}', - '--EXAMPLE', - 'Content-Disposition: form-data; name="retries"', - '', - '[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"missing","to":"existing"}]}]', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -300,10 +276,27 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { }, ], }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ success: true, successCount: 1 }); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_resolve_import_errors') + .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') + .send( + [ + '--EXAMPLE', + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', + 'Content-Type: application/ndjson', + '', + '{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"missing"}]}', + '--EXAMPLE', + 'Content-Disposition: form-data; name="retries"', + '', + '[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"missing","to":"existing"}]}]', + '--EXAMPLE--', + ].join('\r\n') + ) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 1 }); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ diff --git a/src/legacy/server/saved_objects/routes/_mock_server.ts b/src/core/server/saved_objects/routes/integration_tests/test_utils.ts similarity index 51% rename from src/legacy/server/saved_objects/routes/_mock_server.ts rename to src/core/server/saved_objects/routes/integration_tests/test_utils.ts index 10b8c1aa07959..093b36a413214 100644 --- a/src/legacy/server/saved_objects/routes/_mock_server.ts +++ b/src/core/server/saved_objects/routes/integration_tests/test_utils.ts @@ -17,35 +17,29 @@ * under the License. */ -import Hapi from 'hapi'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { defaultValidationErrorHandler } from '../../../../core/server/http/http_tools'; +import { ContextService } from '../../../context'; +import { createHttpServer, createCoreContext } from '../../../http/test_utils'; +import { coreMock } from '../../../mocks'; -const defaultConfig = { - 'kibana.index': '.kibana', - 'savedObjects.maxImportExportSize': 10000, - 'savedObjects.maxImportPayloadBytes': 52428800, -}; +const coreId = Symbol('core'); + +export const setupServer = async () => { + const coreContext = createCoreContext({ coreId }); + const contextService = new ContextService(coreContext); -export function createMockServer(config: { [key: string]: any } = defaultConfig) { - const server = new Hapi.Server({ - port: 0, - routes: { - validate: { - failAction: defaultValidationErrorHandler, - }, - }, + const server = createHttpServer(coreContext); + const httpSetup = await server.setup({ + context: contextService.setup({ pluginDependencies: new Map() }), }); - server.config = () => { - return { - get(key: string) { - return config[key]; - }, - has(key: string) { - return config.hasOwnProperty(key); - }, - }; - }; + const handlerContext = coreMock.createRequestHandlerContext(); - return server; -} + httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { + return handlerContext; + }); + + return { + server, + httpSetup, + handlerContext, + }; +}; diff --git a/src/legacy/server/saved_objects/routes/update.test.ts b/src/core/server/saved_objects/routes/integration_tests/update.test.ts similarity index 56% rename from src/legacy/server/saved_objects/routes/update.test.ts rename to src/core/server/saved_objects/routes/integration_tests/update.test.ts index aaeaff489d30a..b0c3d68090db6 100644 --- a/src/legacy/server/saved_objects/routes/update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/update.test.ts @@ -17,16 +17,21 @@ * under the License. */ -import Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createUpdateRoute } from './update'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerUpdateRoute } from '../update'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { setupServer } from './test_utils'; + +type setupServerReturn = UnwrapPromise>; describe('PUT /api/saved_objects/{type}/{id?}', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let handlerContext: setupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; - beforeEach(() => { + beforeEach(async () => { const clientResponse = { id: 'logstash-*', title: 'logstash-*', @@ -36,37 +41,22 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { notExpandable: true, references: [], }; - savedObjectsClient.update.mockImplementation(() => Promise.resolve(clientResponse)); - server = createMockServer(); - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.update.mockResolvedValue(clientResponse); - server.route(createUpdateRoute(prereqs)); + const router = httpSetup.createRouter('/api/saved_objects/'); + registerUpdateRoute(router); + + await server.start(); }); - afterEach(() => { - savedObjectsClient.update.mockReset(); + afterEach(async () => { + await server.stop(); }); it('formats successful response', async () => { - const request = { - method: 'PUT', - url: '/api/saved_objects/index-pattern/logstash-*', - payload: { - attributes: { - title: 'Testing', - }, - references: [], - }, - }; - const clientResponse = { id: 'logstash-*', title: 'logstash-*', @@ -76,27 +66,29 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { attributes: {}, references: [], }; + savedObjectsClient.update.mockResolvedValue(clientResponse); - savedObjectsClient.update.mockImplementation(() => Promise.resolve(clientResponse)); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); + const result = await supertest(httpSetup.server.listener) + .put('/api/saved_objects/index-pattern/logstash-*') + .send({ + attributes: { + title: 'Testing', + }, + references: [], + }) + .expect(200); - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); + expect(result.body).toEqual(clientResponse); }); it('calls upon savedObjectClient.update', async () => { - const request = { - method: 'PUT', - url: '/api/saved_objects/index-pattern/logstash-*', - payload: { + await supertest(httpSetup.server.listener) + .put('/api/saved_objects/index-pattern/logstash-*') + .send({ attributes: { title: 'Testing' }, version: 'foo', - }, - }; - - await server.inject(request); + }) + .expect(200); expect(savedObjectsClient.update).toHaveBeenCalledWith( 'index-pattern', diff --git a/src/legacy/server/saved_objects/routes/log_legacy_import.ts b/src/core/server/saved_objects/routes/log_legacy_import.ts similarity index 65% rename from src/legacy/server/saved_objects/routes/log_legacy_import.ts rename to src/core/server/saved_objects/routes/log_legacy_import.ts index 038c03d30e030..459b38abb9874 100644 --- a/src/legacy/server/saved_objects/routes/log_legacy_import.ts +++ b/src/core/server/saved_objects/routes/log_legacy_import.ts @@ -17,18 +17,18 @@ * under the License. */ -import Hapi from 'hapi'; +import { IRouter } from '../../http'; +import { Logger } from '../../logging'; -export const createLogLegacyImportRoute = () => ({ - path: '/api/saved_objects/_log_legacy_import', - method: 'POST', - options: { - handler(request: Hapi.Request) { - request.server.log( - ['warning'], - 'Importing saved objects from a .json file has been deprecated' - ); - return { success: true }; +export const registerLogLegacyImportRoute = (router: IRouter, logger: Logger) => { + router.post( + { + path: '/_log_legacy_import', + validate: false, }, - }, -}); + async (context, req, res) => { + logger.warn('Importing saved objects from a .json file has been deprecated'); + return res.ok({ body: { success: true } }); + } + ); +}; diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts new file mode 100644 index 0000000000000..efa7add7951b0 --- /dev/null +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -0,0 +1,89 @@ +/* + * 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 { extname } from 'path'; +import { Readable } from 'stream'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; +import { resolveImportErrors } from '../import'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { createSavedObjectsStreamFromNdJson } from './utils'; + +interface FileStream extends Readable { + hapi: { + filename: string; + }; +} + +export const registerResolveImportErrorsRoute = ( + router: IRouter, + config: SavedObjectConfig, + supportedTypes: string[] +) => { + const { maxImportExportSize, maxImportPayloadBytes } = config; + + router.post( + { + path: '/_resolve_import_errors', + options: { + body: { + maxBytes: maxImportPayloadBytes, + output: 'stream', + accepts: 'multipart/form-data', + }, + }, + validate: { + body: schema.object({ + file: schema.stream(), + retries: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + overwrite: schema.boolean({ defaultValue: false }), + replaceReferences: schema.arrayOf( + schema.object({ + type: schema.string(), + from: schema.string(), + to: schema.string(), + }), + { defaultValue: [] } + ), + }) + ), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const file = req.body.file as FileStream; + const fileExtension = extname(file.hapi.filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); + } + const result = await resolveImportErrors({ + supportedTypes, + savedObjectsClient: context.core.savedObjects.client, + readStream: createSavedObjectsStreamFromNdJson(file), + retries: req.body.retries, + objectLimit: maxImportExportSize, + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/update.ts b/src/core/server/saved_objects/routes/update.ts new file mode 100644 index 0000000000000..c0d94d362e648 --- /dev/null +++ b/src/core/server/saved_objects/routes/update.ts @@ -0,0 +1,56 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerUpdateRoute = (router: IRouter) => { + router.put( + { + path: '/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + body: schema.object({ + attributes: schema.recordOf(schema.string(), schema.any()), + version: schema.maybe(schema.string()), + references: schema.maybe( + schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + id: schema.string(), + }) + ) + ), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const { attributes, version, references } = req.body; + const options = { version, references }; + + const result = await context.core.savedObjects.client.update(type, id, attributes, options); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.test.ts b/src/core/server/saved_objects/routes/utils.test.ts similarity index 96% rename from src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.test.ts rename to src/core/server/saved_objects/routes/utils.test.ts index 342063fefaec6..83dceda2e1398 100644 --- a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -17,9 +17,9 @@ * under the License. */ -import { createSavedObjectsStreamFromNdJson } from './create_saved_objects_stream_from_ndjson'; +import { createSavedObjectsStreamFromNdJson } from './utils'; import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from '../../../utils/streams'; +import { createPromiseFromStreams, createConcatStream } from '../../../../legacy/utils/streams'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); diff --git a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts b/src/core/server/saved_objects/routes/utils.ts similarity index 92% rename from src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts rename to src/core/server/saved_objects/routes/utils.ts index b96514054db56..5536391341da3 100644 --- a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -16,9 +16,14 @@ * specific language governing permissions and limitations * under the License. */ + import { Readable } from 'stream'; import { SavedObject, SavedObjectsExportResultDetails } from 'src/core/server'; -import { createSplitStream, createMapStream, createFilterStream } from '../../../utils/streams'; +import { + createSplitStream, + createMapStream, + createFilterStream, +} from '../../../../legacy/utils/streams'; export function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { return ndJsonStream diff --git a/src/core/server/saved_objects/saved_objects_config.ts b/src/core/server/saved_objects/saved_objects_config.ts index 7217cde55d061..cac04003f29b2 100644 --- a/src/core/server/saved_objects/saved_objects_config.ts +++ b/src/core/server/saved_objects/saved_objects_config.ts @@ -19,9 +19,9 @@ import { schema, TypeOf } from '@kbn/config-schema'; -export type SavedObjectsConfigType = TypeOf; +export type SavedObjectsMigrationConfigType = TypeOf; -export const config = { +export const savedObjectsMigrationConfig = { path: 'migrations', schema: schema.object({ batchSize: schema.number({ defaultValue: 100 }), @@ -30,3 +30,29 @@ export const config = { skip: schema.boolean({ defaultValue: false }), }), }; + +export type SavedObjectsConfigType = TypeOf; + +export const savedObjectsConfig = { + path: 'savedObjects', + schema: schema.object({ + maxImportPayloadBytes: schema.byteSize({ defaultValue: 10485760 }), + maxImportExportSize: schema.byteSize({ defaultValue: 10000 }), + }), +}; + +export class SavedObjectConfig { + public maxImportPayloadBytes: number; + public maxImportExportSize: number; + + public migration: SavedObjectsMigrationConfigType; + + constructor( + rawConfig: SavedObjectsConfigType, + rawMigrationConfig: SavedObjectsMigrationConfigType + ) { + this.maxImportPayloadBytes = rawConfig.maxImportPayloadBytes.getValueInBytes(); + this.maxImportExportSize = rawConfig.maxImportExportSize.getValueInBytes(); + this.migration = rawMigrationConfig; + } +} diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 07e7d8db8b25c..0c7bedecf39f5 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -24,6 +24,7 @@ import { typeRegistryInstanceMock, } from './saved_objects_service.test.mocks'; +import { ByteSizeValue } from '@kbn/config-schema'; import { SavedObjectsService } from './saved_objects_service'; import { mockCoreContext } from '../core_context.mock'; import * as legacyElasticsearch from 'elasticsearch'; @@ -31,14 +32,33 @@ import { Env } from '../config'; import { configServiceMock } from '../mocks'; import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; import { legacyServiceMock } from '../legacy/legacy_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; import { SavedObjectsClientFactoryProvider } from './service/lib'; import { BehaviorSubject } from 'rxjs'; import { NodesVersionCompatibility } from '../elasticsearch/version_check/ensure_es_version'; describe('SavedObjectsService', () => { + const createCoreContext = ({ + skipMigration = true, + env, + }: { skipMigration?: boolean; env?: Env } = {}) => { + const configService = configServiceMock.create({ atPath: { skip: true } }); + configService.atPath.mockImplementation(path => { + if (path === 'migrations') { + return new BehaviorSubject({ skip: skipMigration }); + } + return new BehaviorSubject({ + maxImportPayloadBytes: new ByteSizeValue(0), + maxImportExportSize: new ByteSizeValue(0), + }); + }); + return mockCoreContext.create({ configService, env }); + }; + const createSetupDeps = () => { const elasticsearchMock = elasticsearchServiceMock.createInternalSetup(); return { + http: httpServiceMock.createSetupContract(), elasticsearch: elasticsearchMock, legacyPlugins: legacyServiceMock.createDiscoverPlugins(), }; @@ -51,7 +71,7 @@ describe('SavedObjectsService', () => { describe('#setup()', () => { describe('#setClientFactoryProvider', () => { it('registers the factory to the clientProvider', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); @@ -65,7 +85,7 @@ describe('SavedObjectsService', () => { expect(clientProviderInstanceMock.setClientFactory).toHaveBeenCalledWith(factory); }); it('throws if a factory is already registered', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); @@ -84,7 +104,7 @@ describe('SavedObjectsService', () => { describe('#addClientWrapper', () => { it('registers the wrapper to the clientProvider', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); @@ -112,7 +132,7 @@ describe('SavedObjectsService', () => { describe('registerType', () => { it('registers the type to the internal typeRegistry', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); @@ -132,7 +152,7 @@ describe('SavedObjectsService', () => { describe('#start()', () => { it('creates a KibanaMigrator which retries NoConnections errors from callAsInternalUser', async () => { - const coreContext = mockCoreContext.create(); + const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const coreSetup = createSetupDeps(); @@ -153,7 +173,7 @@ describe('SavedObjectsService', () => { }); it('skips KibanaMigrator migrations when --optimize=true', async () => { - const coreContext = mockCoreContext.create({ + const coreContext = createCoreContext({ env: ({ cliArgs: { optimize: true }, packageInfo: { version: 'x.x.x' } } as unknown) as Env, }); const soService = new SavedObjectsService(coreContext); @@ -164,8 +184,7 @@ describe('SavedObjectsService', () => { }); it('skips KibanaMigrator migrations when migrations.skip=true', async () => { - const configService = configServiceMock.create({ atPath: { skip: true } }); - const coreContext = mockCoreContext.create({ configService }); + const coreContext = createCoreContext({ skipMigration: true }); const soService = new SavedObjectsService(coreContext); await soService.setup(createSetupDeps()); await soService.start({}); @@ -174,8 +193,7 @@ describe('SavedObjectsService', () => { it('waits for all es nodes to be compatible before running migrations', async done => { expect.assertions(2); - const configService = configServiceMock.create({ atPath: { skip: false } }); - const coreContext = mockCoreContext.create({ configService }); + const coreContext = createCoreContext({ skipMigration: false }); const soService = new SavedObjectsService(coreContext); const setupDeps = createSetupDeps(); // Create an new subject so that we can control when isCompatible=true @@ -204,8 +222,7 @@ describe('SavedObjectsService', () => { }); it('resolves with KibanaMigrator after waiting for migrations to complete', async () => { - const configService = configServiceMock.create({ atPath: { skip: false } }); - const coreContext = mockCoreContext.create({ configService }); + const coreContext = createCoreContext({ skipMigration: false }); const soService = new SavedObjectsService(coreContext); await soService.setup(createSetupDeps()); expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(0); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 2f07dbe51a09e..ece00539536e1 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -31,9 +31,13 @@ import { LegacyServiceDiscoverPlugins } from '../legacy'; import { InternalElasticsearchServiceSetup, APICaller } from '../elasticsearch'; import { KibanaConfigType } from '../kibana_config'; import { migrationsRetryCallCluster } from '../elasticsearch/retry_call_cluster'; -import { SavedObjectsConfigType } from './saved_objects_config'; -import { KibanaRequest } from '../http'; -import { SavedObjectsClientContract, SavedObjectsType } from './types'; +import { + SavedObjectsConfigType, + SavedObjectsMigrationConfigType, + SavedObjectConfig, +} from './saved_objects_config'; +import { InternalHttpServiceSetup, KibanaRequest } from '../http'; +import { SavedObjectsClientContract, SavedObjectsType, SavedObjectsLegacyUiExports } from './types'; import { ISavedObjectsRepository, SavedObjectsRepository } from './service/lib/repository'; import { SavedObjectsClientFactoryProvider, @@ -43,6 +47,7 @@ import { Logger } from '../logging'; import { convertLegacyTypes } from './utils'; import { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; import { PropertyValidators } from './validation'; +import { registerRoutes } from './routes'; import { SavedObjectsSerializer } from './serialization'; /** @@ -198,6 +203,7 @@ export interface SavedObjectsRepositoryFactory { export interface SavedObjectsSetupDeps { legacyPlugins: LegacyServiceDiscoverPlugins; elasticsearch: InternalElasticsearchServiceSetup; + http: InternalHttpServiceSetup; } interface WrappedClientFactoryWrapper { @@ -215,6 +221,7 @@ export class SavedObjectsService private logger: Logger; private setupDeps?: SavedObjectsSetupDeps; + private config?: SavedObjectConfig; private clientFactoryProvider?: SavedObjectsClientFactoryProvider; private clientFactoryWrappers: WrappedClientFactoryWrapper[] = []; @@ -237,6 +244,27 @@ export class SavedObjectsService legacyTypes.forEach(type => this.typeRegistry.registerType(type)); this.validations = setupDeps.legacyPlugins.uiExports.savedObjectValidations || {}; + const importableExportableTypes = getImportableAndExportableTypes( + setupDeps.legacyPlugins.uiExports + ); + + const savedObjectsConfig = await this.coreContext.configService + .atPath('savedObjects') + .pipe(first()) + .toPromise(); + const savedObjectsMigrationConfig = await this.coreContext.configService + .atPath('migrations') + .pipe(first()) + .toPromise(); + this.config = new SavedObjectConfig(savedObjectsConfig, savedObjectsMigrationConfig); + + registerRoutes({ + http: setupDeps.http, + logger: this.logger, + config: this.config, + importableExportableTypes, + }); + return { setClientFactoryProvider: provider => { if (this.clientFactoryProvider) { @@ -261,7 +289,7 @@ export class SavedObjectsService core: SavedObjectsStartDeps, migrationsRetryDelay?: number ): Promise { - if (!this.setupDeps) { + if (!this.setupDeps || !this.config) { throw new Error('#setup() needs to be run first'); } @@ -271,12 +299,8 @@ export class SavedObjectsService .atPath('kibana') .pipe(first()) .toPromise(); - const savedObjectsConfig = await this.coreContext.configService - .atPath('migrations') - .pipe(first()) - .toPromise(); const adminClient = this.setupDeps!.elasticsearch.adminClient; - const migrator = this.createMigrator(kibanaConfig, savedObjectsConfig, migrationsRetryDelay); + const migrator = this.createMigrator(kibanaConfig, this.config.migration, migrationsRetryDelay); /** * Note: We want to ensure that migrations have completed before @@ -289,7 +313,7 @@ export class SavedObjectsService * So, when the `migrations.skip` is true, we skip migrations altogether. */ const cliArgs = this.coreContext.env.cliArgs; - const skipMigrations = cliArgs.optimize || savedObjectsConfig.skip; + const skipMigrations = cliArgs.optimize || this.config.migration.skip; if (skipMigrations) { this.logger.warn( @@ -354,7 +378,7 @@ export class SavedObjectsService private createMigrator( kibanaConfig: KibanaConfigType, - savedObjectsConfig: SavedObjectsConfigType, + savedObjectsConfig: SavedObjectsMigrationConfigType, migrationsRetryDelay?: number ): KibanaMigrator { const adminClient = this.setupDeps!.elasticsearch.adminClient; @@ -374,3 +398,16 @@ export class SavedObjectsService }); } } + +function getImportableAndExportableTypes({ + savedObjectMappings = [], + savedObjectsManagement = {}, +}: SavedObjectsLegacyUiExports) { + const visibleTypes = savedObjectMappings.reduce( + (types, mapping) => [...types, ...Object.keys(mapping.properties)], + [] as string[] + ); + return visibleTypes.filter( + type => savedObjectsManagement[type]?.isImportableAndExportable === true ?? false + ); +} diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 980ba005e0eeb..a4fde1765b7d3 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -21,6 +21,7 @@ import { SavedObjectsClient } from './service/saved_objects_client'; import { SavedObjectsTypeMappingDefinition, SavedObjectsTypeMappingDefinitions } from './mappings'; import { SavedObjectMigrationMap } from './migrations'; import { PropertyValidators } from './validation'; +import { SavedObjectsManagementDefinition } from './management'; export { SavedObjectsImportResponse, @@ -256,6 +257,7 @@ export interface SavedObjectsLegacyUiExports { savedObjectMigrations: SavedObjectsLegacyMigrationDefinitions; savedObjectSchemas: SavedObjectsLegacySchemaDefinitions; savedObjectValidations: PropertyValidators; + savedObjectsManagement: SavedObjectsManagementDefinition; } /** diff --git a/src/core/server/saved_objects/utils.test.ts b/src/core/server/saved_objects/utils.test.ts index d1c15517e94a6..1e2b9f6a0f694 100644 --- a/src/core/server/saved_objects/utils.test.ts +++ b/src/core/server/saved_objects/utils.test.ts @@ -61,6 +61,7 @@ describe('convertLegacyTypes', () => { savedObjectMigrations: {}, savedObjectSchemas: {}, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); @@ -100,6 +101,7 @@ describe('convertLegacyTypes', () => { }, }, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); @@ -134,6 +136,7 @@ describe('convertLegacyTypes', () => { }, }, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); @@ -182,6 +185,7 @@ describe('convertLegacyTypes', () => { }, savedObjectSchemas: {}, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); @@ -244,6 +248,7 @@ describe('convertLegacyTypes', () => { }, }, savedObjectValidations: {}, + savedObjectsManagement: {}, }; const converted = convertLegacyTypes(uiExports, legacyConfig); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 96adb3bbcd210..db2493b38d6e0 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -42,7 +42,7 @@ import { config as loggingConfig } from './logging'; import { config as devConfig } from './dev'; import { config as pathConfig } from './path'; import { config as kibanaConfig } from './kibana_config'; -import { config as savedObjectsConfig } from './saved_objects'; +import { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects'; import { config as uiSettingsConfig } from './ui_settings'; import { mapToObject } from '../utils'; import { ContextService } from './context'; @@ -132,6 +132,7 @@ export class Server { }); const savedObjectsSetup = await this.savedObjects.setup({ + http: httpSetup, elasticsearch: elasticsearchServiceSetup, legacyPlugins, }); @@ -257,6 +258,7 @@ export class Server { [devConfig.path, devConfig.schema], [kibanaConfig.path, kibanaConfig.schema], [savedObjectsConfig.path, savedObjectsConfig.schema], + [savedObjectsMigrationConfig.path, savedObjectsMigrationConfig.schema], [uiSettingsConfig.path, uiSettingsConfig.schema], ]; diff --git a/src/legacy/core_plugins/kibana/migrations/types.ts b/src/legacy/core_plugins/kibana/migrations/types.ts index 144151ed80d43..839f753670b20 100644 --- a/src/legacy/core_plugins/kibana/migrations/types.ts +++ b/src/legacy/core_plugins/kibana/migrations/types.ts @@ -17,7 +17,8 @@ * under the License. */ -import { SavedObjectReference } from '../../../../legacy/server/saved_objects/routes/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SavedObjectReference } from '../../../../core/server'; export interface SavedObjectAttributes { kibanaSavedObjectMeta: { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js index a370c66ae330b..038f783a0daf1 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js @@ -19,13 +19,11 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { getAngularModule, getServices, subscribeWithScope } from '../../kibana_services'; - +import { getAngularModule, getServices } from '../../kibana_services'; import './context_app'; +import { getState } from './context_state'; import contextAppRouteTemplate from './context.html'; import { getRootBreadcrumbs } from '../helpers/breadcrumbs'; -import { FilterStateManager } from '../../../../../data/public'; -const { chrome } = getServices(); const k7Breadcrumbs = $route => { const { indexPattern } = $route.current.locals; @@ -68,53 +66,50 @@ getAngularModule().config($routeProvider => { }); }); -function ContextAppRouteController( - $routeParams, - $scope, - AppState, - config, - $route, - getAppState, - globalState -) { +function ContextAppRouteController($routeParams, $scope, config, $route) { const filterManager = getServices().filterManager; - const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager); const indexPattern = $route.current.locals.indexPattern.ip; + const { + startSync: startStateSync, + stopSync: stopStateSync, + appState, + getFilters, + setFilters, + setAppState, + } = getState({ + defaultStepSize: config.get('context:defaultSize'), + timeFieldName: indexPattern.timeFieldName, + storeInSessionStorage: config.get('state:storeInSessionStorage'), + }); + this.state = { ...appState.getState() }; + this.anchorId = $routeParams.id; + this.indexPattern = indexPattern; + this.discoverUrl = getServices().chrome.navLinks.get('kibana:discover').url; + filterManager.setFilters(_.cloneDeep(getFilters())); + startStateSync(); - this.state = new AppState(createDefaultAppState(config, indexPattern)); - this.state.save(true); - + // take care of parameter changes in UI $scope.$watchGroup( [ 'contextAppRoute.state.columns', 'contextAppRoute.state.predecessorCount', 'contextAppRoute.state.successorCount', ], - () => this.state.save(true) + newValues => { + const [columns, predecessorCount, successorCount] = newValues; + if (Array.isArray(columns) && predecessorCount >= 0 && successorCount >= 0) { + setAppState({ columns, predecessorCount, successorCount }); + } + } ); - - const updateSubsciption = subscribeWithScope($scope, filterManager.getUpdates$(), { - next: () => { - this.filters = _.cloneDeep(filterManager.getFilters()); - }, + // take care of parameter filter changes + const filterObservable = filterManager.getUpdates$().subscribe(() => { + setFilters(filterManager); + $route.reload(); }); $scope.$on('$destroy', () => { - filterStateManager.destroy(); - updateSubsciption.unsubscribe(); + stopStateSync(); + filterObservable.unsubscribe(); }); - this.anchorId = $routeParams.id; - this.indexPattern = indexPattern; - this.discoverUrl = chrome.navLinks.get('kibana:discover').url; - this.filters = _.cloneDeep(filterManager.getFilters()); -} - -function createDefaultAppState(config, indexPattern) { - return { - columns: ['_source'], - filters: [], - predecessorCount: parseInt(config.get('context:defaultSize'), 10), - sort: [indexPattern.timeFieldName, 'desc'], - successorCount: parseInt(config.get('context:defaultSize'), 10), - }; } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts index a9c6918adbfde..b91ef5a6b79fb 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts @@ -67,7 +67,7 @@ function fetchContextProvider(indexPatterns: IndexPatternsContract) { size: number, filters: Filter[] ) { - if (typeof anchor !== 'object' || anchor === null) { + if (typeof anchor !== 'object' || anchor === null || !size) { return []; } const indexPattern = await indexPatterns.get(indexPatternId); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js index 966ecffda7755..1cebb88cbda5a 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js @@ -88,9 +88,11 @@ export function QueryActionsProvider(Promise) { const fetchSurroundingRows = (type, state) => { const { - queryParameters: { indexPatternId, filters, sort, tieBreakerField }, + queryParameters: { indexPatternId, sort, tieBreakerField }, rows: { anchor }, } = state; + const filters = getServices().filterManager.getFilters(); + const count = type === 'successors' ? state.queryParameters.successorCount diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts new file mode 100644 index 0000000000000..1fa71ed11643a --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts @@ -0,0 +1,193 @@ +/* + * 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 { getState } from './context_state'; +import { createBrowserHistory, History } from 'history'; +import { FilterManager, Filter } from '../../../../../../../plugins/data/public'; +import { coreMock } from '../../../../../../../core/public/mocks'; +const setupMock = coreMock.createSetup(); + +describe('Test Discover Context State', () => { + let history: History; + let state: any; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(async () => { + history = createBrowserHistory(); + history.push('/'); + state = await getState({ + defaultStepSize: '4', + timeFieldName: 'time', + history, + }); + state.startSync(); + }); + afterEach(() => { + state.stopSync(); + }); + test('getState function default return', () => { + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "filters": Array [], + "predecessorCount": 4, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 4, + } + `); + expect(state.globalState.getState()).toMatchInlineSnapshot(`null`); + expect(state.startSync).toBeDefined(); + expect(state.stopSync).toBeDefined(); + expect(state.getFilters()).toStrictEqual([]); + }); + test('getState -> setAppState syncing to url', async () => { + state.setAppState({ predecessorCount: 10 }); + state.flushToUrl(); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,sort:!(time,desc),successorCount:4)"` + ); + }); + test('getState -> url to appState syncing', async () => { + history.push( + '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' + ); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + }); + test('getState -> url to appState syncing with return to a url without state', async () => { + history.push( + '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' + ); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + history.push('/'); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + }); + + test('getState -> filters', async () => { + const filterManager = new FilterManager(setupMock.uiSettings); + const filterGlobal = { + query: { match: { extension: { query: 'jpg', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + } as Filter; + filterManager.setGlobalFilters([filterGlobal]); + const filterApp = { + query: { match: { extension: { query: 'png', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: true, disabled: false, alias: null }, + } as Filter; + filterManager.setAppFilters([filterApp]); + state.setFilters(filterManager); + expect(state.getFilters()).toMatchInlineSnapshot(` + Array [ + Object { + "$state": Object { + "store": "globalState", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": "logstash-*", + "key": "extension", + "negate": false, + "params": Object { + "query": "jpg", + }, + "type": "phrase", + "value": [Function], + }, + "query": Object { + "match": Object { + "extension": Object { + "query": "jpg", + "type": "phrase", + }, + }, + }, + }, + Object { + "$state": Object { + "store": "appState", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": "logstash-*", + "key": "extension", + "negate": true, + "params": Object { + "query": "png", + }, + "type": "phrase", + "value": [Function], + }, + "query": Object { + "match": Object { + "extension": Object { + "query": "png", + "type": "phrase", + }, + }, + }, + }, + ] + `); + state.flushToUrl(); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match:(extension:(query:jpg,type:phrase))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match:(extension:(query:png,type:phrase))))),predecessorCount:4,sort:!(time,desc),successorCount:4)"` + ); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts new file mode 100644 index 0000000000000..8fb6140d55e31 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts @@ -0,0 +1,275 @@ +/* + * 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 { createBrowserHistory, History } from 'history'; +import { + createStateContainer, + createKbnUrlStateStorage, + syncStates, + BaseStateContainer, +} from '../../../../../../../plugins/kibana_utils/public'; +import { esFilters, FilterManager, Filter } from '../../../../../../../plugins/data/public'; + +interface AppState { + /** + * Columns displayed in the table, cannot be changed by UI, just in discover's main app + */ + columns: string[]; + /** + * Array of filters + */ + filters: Filter[]; + /** + * Number of records to be fetched before anchor records (newer records) + */ + predecessorCount: number; + /** + * Sorting of the records to be fetched, assumed to be a legacy parameter + */ + sort: string[]; + /** + * Number of records to be fetched after the anchor records (older records) + */ + successorCount: number; +} + +interface GlobalState { + /** + * Array of filters + */ + filters: Filter[]; +} + +interface GetStateParams { + /** + * Number of records to be fetched when 'Load' link/button is clicked + */ + defaultStepSize: string; + /** + * The timefield used for sorting + */ + timeFieldName: string; + /** + * Determins the use of long vs. short/hashed urls + */ + storeInSessionStorage?: boolean; + /** + * Browser history used for testing + */ + history?: History; +} + +interface GetStateReturn { + /** + * Global state, the _g part of the URL + */ + globalState: BaseStateContainer; + /** + * App state, the _a part of the URL + */ + appState: BaseStateContainer; + /** + * Start sync between state and URL + */ + startSync: () => void; + /** + * Stop sync between state and URL + */ + stopSync: () => void; + /** + * Set app state to with a partial new app state + */ + setAppState: (newState: Partial) => void; + /** + * Get all filters, global and app state + */ + getFilters: () => Filter[]; + /** + * Set global state and app state filters by the given FilterManager instance + * @param filterManager + */ + setFilters: (filterManager: FilterManager) => void; + /** + * sync state to URL, used for testing + */ + flushToUrl: () => void; +} +const GLOBAL_STATE_URL_KEY = '_g'; +const APP_STATE_URL_KEY = '_a'; + +/** + * Builds and returns appState and globalState containers + * provides helper functions to start/stop syncing with URL + */ +export function getState({ + defaultStepSize, + timeFieldName, + storeInSessionStorage = false, + history, +}: GetStateParams): GetStateReturn { + const stateStorage = createKbnUrlStateStorage({ + useHash: storeInSessionStorage, + history: history ? history : createBrowserHistory(), + }); + + const globalStateInitial = stateStorage.get(GLOBAL_STATE_URL_KEY) as GlobalState; + const globalStateContainer = createStateContainer(globalStateInitial); + + const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState; + const appStateInitial = createInitialAppState(defaultStepSize, timeFieldName, appStateFromUrl); + const appStateContainer = createStateContainer(appStateInitial); + + const { start, stop } = syncStates([ + { + storageKey: GLOBAL_STATE_URL_KEY, + stateContainer: { + ...globalStateContainer, + ...{ + set: (value: GlobalState | null) => { + if (value) { + globalStateContainer.set(value); + } + }, + }, + }, + stateStorage, + }, + { + storageKey: APP_STATE_URL_KEY, + stateContainer: { + ...appStateContainer, + ...{ + set: (value: AppState | null) => { + if (value) { + appStateContainer.set(value); + } + }, + }, + }, + stateStorage, + }, + ]); + + return { + globalState: globalStateContainer, + appState: appStateContainer, + startSync: start, + stopSync: stop, + setAppState: (newState: Partial) => { + const oldState = appStateContainer.getState(); + const mergedState = { ...oldState, ...newState }; + + if (!isEqualState(oldState, mergedState)) { + appStateContainer.set(mergedState); + } + }, + getFilters: () => [ + ...getFilters(globalStateContainer.getState()), + ...getFilters(appStateContainer.getState()), + ], + setFilters: (filterManager: FilterManager) => { + // global state filters + const globalFilters = filterManager.getGlobalFilters(); + const globalFilterChanged = !isEqualFilters( + globalFilters, + getFilters(globalStateContainer.getState()) + ); + if (globalFilterChanged) { + globalStateContainer.set({ filters: globalFilters }); + } + // app state filters + const appFilters = filterManager.getAppFilters(); + const appFilterChanged = !isEqualFilters( + appFilters, + getFilters(appStateContainer.getState()) + ); + if (appFilterChanged) { + appStateContainer.set({ ...appStateContainer.getState(), ...{ filters: appFilters } }); + } + }, + // helper function just needed for testing + flushToUrl: () => stateStorage.flush(), + }; +} + +/** + * Helper function to compare 2 different filter states + */ +export function isEqualFilters(filtersA: Filter[], filtersB: Filter[]) { + if (!filtersA && !filtersB) { + return true; + } else if (!filtersA || !filtersB) { + return false; + } + return esFilters.compareFilters(filtersA, filtersB, esFilters.COMPARE_ALL_OPTIONS); +} + +/** + * Helper function to compare 2 different states, is needed since comparing filters + * works differently, doesn't work with _.isEqual + */ +function isEqualState(stateA: AppState | GlobalState, stateB: AppState | GlobalState) { + if (!stateA && !stateB) { + return true; + } else if (!stateA || !stateB) { + return false; + } + const { filters: stateAFilters = [], ...stateAPartial } = stateA; + const { filters: stateBFilters = [], ...stateBPartial } = stateB; + return ( + _.isEqual(stateAPartial, stateBPartial) && + esFilters.compareFilters(stateAFilters, stateBFilters, esFilters.COMPARE_ALL_OPTIONS) + ); +} + +/** + * Helper function to return array of filter object of a given state + */ +function getFilters(state: AppState | GlobalState): Filter[] { + if (!state || !Array.isArray(state.filters)) { + return []; + } + return state.filters; +} + +/** + * Helper function to return the initial app state, which is a merged object of url state and + * default state. The default size is the default number of successor/predecessor records to fetch + */ +function createInitialAppState( + defaultSize: string, + timeFieldName: string, + urlState: AppState +): AppState { + const defaultState = { + columns: ['_source'], + filters: [], + predecessorCount: parseInt(defaultSize, 10), + sort: [timeFieldName, 'desc'], + successorCount: parseInt(defaultSize, 10), + }; + if (typeof urlState !== 'object') { + return defaultState; + } + + return { + ...defaultState, + ...urlState, + }; +} diff --git a/src/legacy/core_plugins/kibana/public/home/kibana_services.ts b/src/legacy/core_plugins/kibana/public/home/kibana_services.ts index 6cb1531be6b5b..4b09997fc6244 100644 --- a/src/legacy/core_plugins/kibana/public/home/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/home/kibana_services.ts @@ -31,7 +31,7 @@ import { TelemetryPluginStart } from '../../../../../plugins/telemetry/public'; import { Environment, HomePublicPluginSetup, - FeatureCatalogueEntry, + HomePublicPluginStart, } from '../../../../../plugins/home/public'; import { KibanaLegacySetup } from '../../../../../plugins/kibana_legacy/public'; @@ -43,7 +43,7 @@ export interface HomeKibanaServices { uiSettings: IUiSettingsClient; config: KibanaLegacySetup['config']; homeConfig: HomePublicPluginSetup['config']; - directories: readonly FeatureCatalogueEntry[]; + featureCatalogue: HomePublicPluginStart['featureCatalogue']; http: HttpStart; savedObjectsClient: SavedObjectsClientContract; toastNotifications: NotificationsSetup['toasts']; diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/application.tsx b/src/legacy/core_plugins/kibana/public/home/np_ready/application.tsx index 2149885f3ee11..578d1f0a766a5 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/application.tsx +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/application.tsx @@ -26,7 +26,11 @@ import { getServices } from '../kibana_services'; export const renderApp = async (element: HTMLElement) => { const homeTitle = i18n.translate('kbn.home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); - const { directories, chrome } = getServices(); + const { featureCatalogue, chrome } = getServices(); + + // all the directories could be get in "start" phase of plugin after all of the legacy plugins will be moved to a NP + const directories = featureCatalogue.get(); + chrome.setBreadcrumbs([{ text: homeTitle }]); render(, element); diff --git a/src/legacy/core_plugins/kibana/public/home/plugin.ts b/src/legacy/core_plugins/kibana/public/home/plugin.ts index 75e7cc2e453be..1f4b5fe7cacef 100644 --- a/src/legacy/core_plugins/kibana/public/home/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/home/plugin.ts @@ -34,7 +34,6 @@ import { Environment, HomePublicPluginStart, HomePublicPluginSetup, - FeatureCatalogueEntry, } from '../../../../../plugins/home/public'; export interface HomePluginStartDependencies { @@ -53,7 +52,7 @@ export class HomePlugin implements Plugin { private dataStart: DataPublicPluginStart | null = null; private savedObjectsClient: any = null; private environment: Environment | null = null; - private directories: readonly FeatureCatalogueEntry[] | null = null; + private featureCatalogue: HomePublicPluginStart['featureCatalogue'] | null = null; private telemetry?: TelemetryPluginStart; constructor(private initializerContext: PluginInitializerContext) {} @@ -83,7 +82,7 @@ export class HomePlugin implements Plugin { environment: this.environment!, config: kibanaLegacy.config, homeConfig: home.config, - directories: this.directories!, + featureCatalogue: this.featureCatalogue!, }); const { renderApp } = await import('./np_ready/application'); return await renderApp(params.element); @@ -93,7 +92,7 @@ export class HomePlugin implements Plugin { start(core: CoreStart, { data, home, telemetry }: HomePluginStartDependencies) { this.environment = home.environment.get(); - this.directories = home.featureCatalogue.get(); + this.featureCatalogue = home.featureCatalogue; this.dataStart = data; this.telemetry = telemetry; this.savedObjectsClient = core.savedObjects.client; diff --git a/src/legacy/server/saved_objects/routes/bulk_create.ts b/src/legacy/server/saved_objects/routes/bulk_create.ts deleted file mode 100644 index b185650494f94..0000000000000 --- a/src/legacy/server/saved_objects/routes/bulk_create.ts +++ /dev/null @@ -1,81 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectAttributes, SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites, SavedObjectReference, WithoutQueryAndParams } from './types'; - -interface SavedObject { - type: string; - id?: string; - attributes: SavedObjectAttributes; - version?: string; - migrationVersion?: { [key: string]: string }; - references: SavedObjectReference[]; -} - -interface BulkCreateRequest extends WithoutQueryAndParams { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - query: { - overwrite: boolean; - }; - payload: SavedObject[]; -} - -export const createBulkCreateRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/_bulk_create', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - query: Joi.object() - .keys({ - overwrite: Joi.boolean().default(false), - }) - .default(), - payload: Joi.array().items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string(), - attributes: Joi.object().required(), - version: Joi.string(), - migrationVersion: Joi.object().optional(), - references: Joi.array() - .items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ) - .default([]), - }).required() - ), - }, - handler(request: BulkCreateRequest) { - const { overwrite } = request.query; - const { savedObjectsClient } = request.pre; - - return savedObjectsClient.bulkCreate(request.payload, { overwrite }); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/bulk_get.ts b/src/legacy/server/saved_objects/routes/bulk_get.ts deleted file mode 100644 index e9eca8e557982..0000000000000 --- a/src/legacy/server/saved_objects/routes/bulk_get.ts +++ /dev/null @@ -1,56 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites } from './types'; - -interface BulkGetRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - payload: Array<{ - type: string; - id: string; - fields?: string[]; - }>; -} - -export const createBulkGetRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/_bulk_get', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - payload: Joi.array().items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string().required(), - fields: Joi.array().items(Joi.string()), - }).required() - ), - }, - handler(request: BulkGetRequest) { - const { savedObjectsClient } = request.pre; - - return savedObjectsClient.bulkGet(request.payload); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/bulk_update.ts b/src/legacy/server/saved_objects/routes/bulk_update.ts deleted file mode 100644 index a77b0c059447f..0000000000000 --- a/src/legacy/server/saved_objects/routes/bulk_update.ts +++ /dev/null @@ -1,61 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClient, SavedObjectsBulkUpdateObject } from 'src/core/server'; -import { Prerequisites } from './types'; - -interface BulkUpdateRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClient; - }; - payload: SavedObjectsBulkUpdateObject[]; -} - -export const createBulkUpdateRoute = (prereqs: Prerequisites) => { - return { - path: '/api/saved_objects/_bulk_update', - method: 'PUT', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - payload: Joi.array().items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string().required(), - attributes: Joi.object().required(), - version: Joi.string(), - references: Joi.array().items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ), - }) - ), - }, - handler(request: BulkUpdateRequest) { - const { savedObjectsClient } = request.pre; - return savedObjectsClient.bulkUpdate(request.payload); - }, - }, - }; -}; diff --git a/src/legacy/server/saved_objects/routes/create.test.ts b/src/legacy/server/saved_objects/routes/create.test.ts deleted file mode 100644 index 4f096a9ee5c93..0000000000000 --- a/src/legacy/server/saved_objects/routes/create.test.ts +++ /dev/null @@ -1,132 +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 Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createCreateRoute } from './create'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -describe('POST /api/saved_objects/{type}', () => { - let server: Hapi.Server; - const clientResponse = { - id: 'logstash-*', - type: 'index-pattern', - title: 'logstash-*', - version: 'foo', - references: [], - attributes: {}, - }; - const savedObjectsClient = savedObjectsClientMock.create(); - - beforeEach(() => { - savedObjectsClient.create.mockImplementation(() => Promise.resolve(clientResponse)); - server = createMockServer(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route(createCreateRoute(prereqs)); - }); - - afterEach(() => { - savedObjectsClient.create.mockReset(); - }); - - it('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/index-pattern', - payload: { - attributes: { - title: 'Testing', - }, - }, - }; - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); - }); - - it('requires attributes', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/index-pattern', - payload: {}, - }; - - const { statusCode, payload } = await server.inject(request); - const response = JSON.parse(payload); - - expect(response.validation.keys).toContain('attributes'); - expect(response.message).toMatch(/is required/); - expect(response.statusCode).toBe(400); - expect(statusCode).toBe(400); - }); - - it('calls upon savedObjectClient.create', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/index-pattern', - payload: { - attributes: { - title: 'Testing', - }, - }, - }; - - await server.inject(request); - expect(savedObjectsClient.create).toHaveBeenCalled(); - - expect(savedObjectsClient.create).toHaveBeenCalledWith( - 'index-pattern', - { title: 'Testing' }, - { overwrite: false, id: undefined, migrationVersion: undefined, references: [] } - ); - }); - - it('can specify an id', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/index-pattern/logstash-*', - payload: { - attributes: { - title: 'Testing', - }, - }, - }; - - await server.inject(request); - expect(savedObjectsClient.create).toHaveBeenCalled(); - - const args = savedObjectsClient.create.mock.calls[0]; - const options = { overwrite: false, id: 'logstash-*', references: [] }; - const attributes = { title: 'Testing' }; - - expect(args).toEqual(['index-pattern', attributes, options]); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/create.ts b/src/legacy/server/saved_objects/routes/create.ts deleted file mode 100644 index a3f4a926972ca..0000000000000 --- a/src/legacy/server/saved_objects/routes/create.ts +++ /dev/null @@ -1,86 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectAttributes, SavedObjectsClient } from 'src/core/server'; -import { Prerequisites, SavedObjectReference, WithoutQueryAndParams } from './types'; - -interface CreateRequest extends WithoutQueryAndParams { - pre: { - savedObjectsClient: SavedObjectsClient; - }; - query: { - overwrite: boolean; - }; - params: { - type: string; - id?: string; - }; - payload: { - attributes: SavedObjectAttributes; - migrationVersion?: { [key: string]: string }; - references: SavedObjectReference[]; - }; -} - -export const createCreateRoute = (prereqs: Prerequisites) => { - return { - path: '/api/saved_objects/{type}/{id?}', - method: 'POST', - options: { - pre: [prereqs.getSavedObjectsClient], - validate: { - query: Joi.object() - .keys({ - overwrite: Joi.boolean().default(false), - }) - .default(), - params: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string(), - }) - .required(), - payload: Joi.object({ - attributes: Joi.object().required(), - migrationVersion: Joi.object().optional(), - references: Joi.array() - .items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ) - .default([]), - }).required(), - }, - handler(request: CreateRequest) { - const { savedObjectsClient } = request.pre; - const { type, id } = request.params; - const { overwrite } = request.query; - const { migrationVersion, references } = request.payload; - const options = { id, overwrite, migrationVersion, references }; - - return savedObjectsClient.create(type, request.payload.attributes, options); - }, - }, - }; -}; diff --git a/src/legacy/server/saved_objects/routes/delete.test.ts b/src/legacy/server/saved_objects/routes/delete.test.ts deleted file mode 100644 index f3e5e83771471..0000000000000 --- a/src/legacy/server/saved_objects/routes/delete.test.ts +++ /dev/null @@ -1,71 +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 Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createDeleteRoute } from './delete'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -describe('DELETE /api/saved_objects/{type}/{id}', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); - - beforeEach(() => { - savedObjectsClient.delete.mockImplementation(() => Promise.resolve('{}')); - server = createMockServer(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route(createDeleteRoute(prereqs)); - }); - - afterEach(() => { - savedObjectsClient.delete.mockReset(); - }); - - it('formats successful response', async () => { - const request = { - method: 'DELETE', - url: '/api/saved_objects/index-pattern/logstash-*', - }; - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - - expect(statusCode).toBe(200); - expect(response).toEqual({}); - }); - - it('calls upon savedObjectClient.delete', async () => { - const request = { - method: 'DELETE', - url: '/api/saved_objects/index-pattern/logstash-*', - }; - - await server.inject(request); - expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*'); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/delete.ts b/src/legacy/server/saved_objects/routes/delete.ts deleted file mode 100644 index a718f26bc2014..0000000000000 --- a/src/legacy/server/saved_objects/routes/delete.ts +++ /dev/null @@ -1,55 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites } from './types'; - -interface DeleteRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - params: { - type: string; - id: string; - }; -} - -export const createDeleteRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/{type}/{id}', - method: 'DELETE', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - params: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string().required(), - }) - .required(), - }, - handler(request: DeleteRequest) { - const { savedObjectsClient } = request.pre; - const { type, id } = request.params; - - return savedObjectsClient.delete(type, id); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/export.test.ts b/src/legacy/server/saved_objects/routes/export.test.ts deleted file mode 100644 index 93ca3a419e6df..0000000000000 --- a/src/legacy/server/saved_objects/routes/export.test.ts +++ /dev/null @@ -1,186 +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('../../../../core/server/saved_objects/export', () => ({ - getSortedObjectsForExport: jest.fn(), -})); - -import Hapi from 'hapi'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import * as exportMock from '../../../../core/server/saved_objects/export'; -import { createMockServer } from './_mock_server'; -import { createExportRoute } from './export'; -import { createListStream } from '../../../utils/streams'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -const getSortedObjectsForExport = exportMock.getSortedObjectsForExport as jest.Mock; - -describe('POST /api/saved_objects/_export', () => { - let server: Hapi.Server; - const savedObjectsClient = { - ...savedObjectsClientMock.create(), - errors: {} as any, - }; - - beforeEach(() => { - server = createMockServer(); - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route(createExportRoute(prereqs, server, ['index-pattern', 'search'])); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - test('does not allow both "search" and "objects" to be specified', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_export', - payload: { - search: 'search', - objects: [{ type: 'search', id: 'bar' }], - includeReferencesDeep: true, - }, - }; - - const { payload, statusCode } = await server.inject(request); - - expect(statusCode).toEqual(400); - expect(JSON.parse(payload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "\\"search\\" must not exist simultaneously with [objects]", - "statusCode": 400, - "validation": Object { - "keys": Array [ - "value", - ], - "source": "payload", - }, - } - `); - }); - - test('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_export', - payload: { - type: 'search', - search: 'my search string', - includeReferencesDeep: true, - }, - }; - getSortedObjectsForExport.mockResolvedValueOnce( - createListStream([ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]) - ); - - const { payload, statusCode, headers } = await server.inject(request); - const objects = payload.split('\n').map(row => JSON.parse(row)); - - expect(statusCode).toBe(200); - expect(headers).toHaveProperty('content-disposition', 'attachment; filename="export.ndjson"'); - expect(headers).toHaveProperty('content-type', 'application/ndjson'); - expect(objects).toMatchInlineSnapshot(` - Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - ] - `); - expect(getSortedObjectsForExport).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "excludeExportDetails": false, - "exportSizeLimit": 10000, - "includeReferencesDeep": true, - "objects": undefined, - "savedObjectsClient": Object { - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "errors": Object {}, - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "search": "my search string", - "types": Array [ - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/export.ts b/src/legacy/server/saved_objects/routes/export.ts deleted file mode 100644 index ce4aed4b78c2a..0000000000000 --- a/src/legacy/server/saved_objects/routes/export.ts +++ /dev/null @@ -1,109 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import stringify from 'json-stable-stringify'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { - createPromiseFromStreams, - createMapStream, - createConcatStream, -} from '../../../utils/streams'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { getSortedObjectsForExport } from '../../../../core/server/saved_objects'; -import { Prerequisites } from './types'; - -interface ExportRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - payload: { - type?: string[]; - objects?: Array<{ - type: string; - id: string; - }>; - search?: string; - includeReferencesDeep: boolean; - excludeExportDetails: boolean; - }; -} - -export const createExportRoute = ( - prereqs: Prerequisites, - server: Hapi.Server, - supportedTypes: string[] -) => ({ - path: '/api/saved_objects/_export', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - payload: Joi.object() - .keys({ - type: Joi.array() - .items(Joi.string().valid(supportedTypes.sort())) - .single() - .optional(), - objects: Joi.array() - .items({ - type: Joi.string() - .valid(supportedTypes.sort()) - .required(), - id: Joi.string().required(), - }) - .max(server.config().get('savedObjects.maxImportExportSize')) - .optional(), - search: Joi.string().optional(), - includeReferencesDeep: Joi.boolean().default(false), - excludeExportDetails: Joi.boolean().default(false), - }) - .xor('type', 'objects') - .nand('search', 'objects') - .default(), - }, - async handler(request: ExportRequest, h: Hapi.ResponseToolkit) { - const { savedObjectsClient } = request.pre; - const exportStream = await getSortedObjectsForExport({ - savedObjectsClient, - types: request.payload.type, - search: request.payload.search, - objects: request.payload.objects, - exportSizeLimit: server.config().get('savedObjects.maxImportExportSize'), - includeReferencesDeep: request.payload.includeReferencesDeep, - excludeExportDetails: request.payload.excludeExportDetails, - }); - - const docsToExport: string[] = await createPromiseFromStreams([ - exportStream, - createMapStream((obj: unknown) => { - return stringify(obj); - }), - createConcatStream([]), - ]); - - return h - .response(docsToExport.join('\n')) - .header('Content-Disposition', `attachment; filename="export.ndjson"`) - .header('Content-Type', 'application/ndjson'); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/find.ts b/src/legacy/server/saved_objects/routes/find.ts deleted file mode 100644 index f8cb8c50d9684..0000000000000 --- a/src/legacy/server/saved_objects/routes/find.ts +++ /dev/null @@ -1,105 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites, WithoutQueryAndParams } from './types'; - -interface FindRequest extends WithoutQueryAndParams { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - query: { - per_page: number; - page: number; - type: string[]; - search?: string; - default_search_operator: 'AND' | 'OR'; - search_fields?: string[]; - sort_field?: string; - has_reference?: { - type: string; - id: string; - }; - fields?: string[]; - filter?: string; - }; -} - -export const createFindRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/_find', - method: 'GET', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - query: Joi.object() - .keys({ - per_page: Joi.number() - .min(0) - .default(20), - page: Joi.number() - .min(0) - .default(1), - type: Joi.array() - .items(Joi.string()) - .single() - .required(), - search: Joi.string() - .allow('') - .optional(), - default_search_operator: Joi.string() - .valid('OR', 'AND') - .default('OR'), - search_fields: Joi.array() - .items(Joi.string()) - .single(), - sort_field: Joi.string(), - has_reference: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string().required(), - }) - .optional(), - fields: Joi.array() - .items(Joi.string()) - .single(), - filter: Joi.string() - .allow('') - .optional(), - }) - .default(), - }, - handler(request: FindRequest) { - const query = request.query; - return request.pre.savedObjectsClient.find({ - perPage: query.per_page, - page: query.page, - type: query.type, - search: query.search, - defaultSearchOperator: query.default_search_operator, - searchFields: query.search_fields, - sortField: query.sort_field, - hasReference: query.has_reference, - fields: query.fields, - filter: query.filter, - }); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/get.test.ts b/src/legacy/server/saved_objects/routes/get.test.ts deleted file mode 100644 index 7ede2a8b4d7b4..0000000000000 --- a/src/legacy/server/saved_objects/routes/get.test.ts +++ /dev/null @@ -1,95 +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 Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createGetRoute } from './get'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -describe('GET /api/saved_objects/{type}/{id}', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); - - beforeEach(() => { - savedObjectsClient.get.mockImplementation(() => - Promise.resolve({ - id: 'logstash-*', - title: 'logstash-*', - type: 'logstash-type', - attributes: {}, - timeFieldName: '@timestamp', - notExpandable: true, - references: [], - }) - ); - server = createMockServer(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route(createGetRoute(prereqs)); - }); - - afterEach(() => { - savedObjectsClient.get.mockReset(); - }); - - it('formats successful response', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/index-pattern/logstash-*', - }; - const clientResponse = { - id: 'logstash-*', - title: 'logstash-*', - type: 'logstash-type', - attributes: {}, - timeFieldName: '@timestamp', - notExpandable: true, - references: [], - }; - - savedObjectsClient.get.mockImplementation(() => Promise.resolve(clientResponse)); - - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - - expect(statusCode).toBe(200); - expect(response).toEqual(clientResponse); - }); - - it('calls upon savedObjectClient.get', async () => { - const request = { - method: 'GET', - url: '/api/saved_objects/index-pattern/logstash-*', - }; - - await server.inject(request); - expect(savedObjectsClient.get).toHaveBeenCalled(); - - const args = savedObjectsClient.get.mock.calls[0]; - expect(args).toEqual(['index-pattern', 'logstash-*']); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/get.ts b/src/legacy/server/saved_objects/routes/get.ts deleted file mode 100644 index 4dbb06d53425a..0000000000000 --- a/src/legacy/server/saved_objects/routes/get.ts +++ /dev/null @@ -1,55 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { Prerequisites } from './types'; - -interface GetRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - params: { - type: string; - id: string; - }; -} - -export const createGetRoute = (prereqs: Prerequisites) => ({ - path: '/api/saved_objects/{type}/{id}', - method: 'GET', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - params: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string().required(), - }) - .required(), - }, - handler(request: GetRequest) { - const { savedObjectsClient } = request.pre; - const { type, id } = request.params; - - return savedObjectsClient.get(type, id); - }, - }, -}); diff --git a/src/legacy/server/saved_objects/routes/import.test.ts b/src/legacy/server/saved_objects/routes/import.test.ts deleted file mode 100644 index 2b8d9d7523507..0000000000000 --- a/src/legacy/server/saved_objects/routes/import.test.ts +++ /dev/null @@ -1,321 +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 Hapi from 'hapi'; -import { createMockServer } from './_mock_server'; -import { createImportRoute } from './import'; -import { savedObjectsClientMock } from '../../../../core/server/mocks'; - -describe('POST /api/saved_objects/_import', () => { - let server: Hapi.Server; - const savedObjectsClient = savedObjectsClientMock.create(); - const emptyResponse = { - saved_objects: [], - total: 0, - per_page: 0, - page: 0, - }; - - beforeEach(() => { - server = createMockServer(); - jest.resetAllMocks(); - - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method() { - return savedObjectsClient; - }, - }, - }; - - server.route( - createImportRoute(prereqs, server, ['index-pattern', 'visualization', 'dashboard']) - ); - }); - - test('formats successful response', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--BOUNDARY', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '', - '--BOUNDARY--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=BOUNDARY', - }, - }; - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: true, - successCount: 0, - }); - expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); - }); - - test('defaults migrationVersion to empty object', async () => { - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: { - title: 'my-pattern-*', - }, - references: [], - }, - ], - }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: true, - successCount: 1, - }); - expect(savedObjectsClient.bulkCreate.mock.calls).toHaveLength(1); - const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0]; - expect(firstBulkCreateCallArray).toHaveLength(1); - expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({}); - }); - - test('imports an index pattern and dashboard, ignoring empty lines in the file', async () => { - // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', - '', - '', - '', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: { - title: 'my-pattern-*', - }, - references: [], - }, - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: true, - successCount: 2, - }); - }); - - test('imports an index pattern and dashboard but has a conflict on the index pattern', async () => { - // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; - savedObjectsClient.find.mockResolvedValueOnce(emptyResponse); - savedObjectsClient.bulkCreate.mockResolvedValueOnce({ - saved_objects: [ - { - type: 'index-pattern', - id: 'my-pattern', - attributes: {}, - references: [], - error: { - statusCode: 409, - message: 'version conflict, document already exists', - }, - }, - { - type: 'dashboard', - id: 'my-dashboard', - attributes: { - title: 'Look at my dashboard', - }, - references: [], - }, - ], - }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: false, - successCount: 1, - errors: [ - { - id: 'my-pattern', - type: 'index-pattern', - title: 'my-pattern-*', - error: { - type: 'conflict', - }, - }, - ], - }); - }); - - test('imports a visualization with missing references', async () => { - // NOTE: changes to this scenario should be reflected in the docs - const request = { - method: 'POST', - url: '/api/saved_objects/_import', - payload: [ - '--EXAMPLE', - 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', - 'Content-Type: application/ndjson', - '', - '{"type":"visualization","id":"my-vis","attributes":{"title":"my-vis"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern-*"}]}', - '{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}', - '--EXAMPLE--', - ].join('\r\n'), - headers: { - 'content-Type': 'multipart/form-data; boundary=EXAMPLE', - }, - }; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: 'my-pattern-*', - type: 'index-pattern', - error: { - statusCode: 404, - message: 'Not found', - }, - references: [], - attributes: {}, - }, - ], - }); - const { payload, statusCode } = await server.inject(request); - const response = JSON.parse(payload); - expect(statusCode).toBe(200); - expect(response).toEqual({ - success: false, - successCount: 0, - errors: [ - { - id: 'my-vis', - type: 'visualization', - title: 'my-vis', - error: { - type: 'missing_references', - references: [ - { - type: 'index-pattern', - id: 'my-pattern-*', - }, - ], - blocking: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], - }, - }, - ], - }); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "my-pattern-*", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); - }); -}); diff --git a/src/legacy/server/saved_objects/routes/import.ts b/src/legacy/server/saved_objects/routes/import.ts deleted file mode 100644 index 53fa3ea7d5d00..0000000000000 --- a/src/legacy/server/saved_objects/routes/import.ts +++ /dev/null @@ -1,91 +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 Boom from 'boom'; -import Hapi from 'hapi'; -import Joi from 'joi'; -import { extname } from 'path'; -import { Readable } from 'stream'; -import { SavedObjectsClientContract } from 'src/core/server'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { importSavedObjects } from '../../../../core/server/saved_objects/import'; -import { Prerequisites, WithoutQueryAndParams } from './types'; -import { createSavedObjectsStreamFromNdJson } from '../lib'; - -interface HapiReadableStream extends Readable { - hapi: { - filename: string; - }; -} - -interface ImportRequest extends WithoutQueryAndParams { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - query: { - overwrite: boolean; - }; - payload: { - file: HapiReadableStream; - }; -} - -export const createImportRoute = ( - prereqs: Prerequisites, - server: Hapi.Server, - supportedTypes: string[] -) => ({ - path: '/api/saved_objects/_import', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - payload: { - maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'), - output: 'stream', - allow: 'multipart/form-data', - }, - validate: { - query: Joi.object() - .keys({ - overwrite: Joi.boolean().default(false), - }) - .default(), - payload: Joi.object({ - file: Joi.object().required(), - }).default(), - }, - }, - async handler(request: ImportRequest, h: Hapi.ResponseToolkit) { - const { savedObjectsClient } = request.pre; - const { filename } = request.payload.file.hapi; - const fileExtension = extname(filename).toLowerCase(); - if (fileExtension !== '.ndjson') { - return Boom.badRequest(`Invalid file extension ${fileExtension}`); - } - - return await importSavedObjects({ - supportedTypes, - savedObjectsClient, - readStream: createSavedObjectsStreamFromNdJson(request.payload.file), - objectLimit: request.server.config().get('savedObjects.maxImportExportSize'), - overwrite: request.query.overwrite, - }); - }, -}); diff --git a/src/legacy/server/saved_objects/routes/resolve_import_errors.ts b/src/legacy/server/saved_objects/routes/resolve_import_errors.ts deleted file mode 100644 index 15d02f525c2cf..0000000000000 --- a/src/legacy/server/saved_objects/routes/resolve_import_errors.ts +++ /dev/null @@ -1,112 +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 Boom from 'boom'; -import Hapi from 'hapi'; -import Joi from 'joi'; -import { extname } from 'path'; -import { Readable } from 'stream'; -import { SavedObjectsClientContract } from 'src/core/server'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { resolveImportErrors } from '../../../../core/server/saved_objects/import'; -import { Prerequisites } from './types'; -import { createSavedObjectsStreamFromNdJson } from '../lib'; - -interface HapiReadableStream extends Readable { - hapi: { - filename: string; - }; -} - -interface ImportRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClientContract; - }; - payload: { - file: HapiReadableStream; - retries: Array<{ - type: string; - id: string; - overwrite: boolean; - replaceReferences: Array<{ - type: string; - from: string; - to: string; - }>; - }>; - }; -} - -export const createResolveImportErrorsRoute = ( - prereqs: Prerequisites, - server: Hapi.Server, - supportedTypes: string[] -) => ({ - path: '/api/saved_objects/_resolve_import_errors', - method: 'POST', - config: { - pre: [prereqs.getSavedObjectsClient], - payload: { - maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'), - output: 'stream', - allow: 'multipart/form-data', - }, - validate: { - payload: Joi.object({ - file: Joi.object().required(), - retries: Joi.array() - .items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string().required(), - overwrite: Joi.boolean().default(false), - replaceReferences: Joi.array() - .items( - Joi.object({ - type: Joi.string().required(), - from: Joi.string().required(), - to: Joi.string().required(), - }) - ) - .default([]), - }) - ) - .required(), - }).default(), - }, - }, - async handler(request: ImportRequest) { - const { savedObjectsClient } = request.pre; - const { filename } = request.payload.file.hapi; - const fileExtension = extname(filename).toLowerCase(); - - if (fileExtension !== '.ndjson') { - return Boom.badRequest(`Invalid file extension ${fileExtension}`); - } - - return await resolveImportErrors({ - supportedTypes, - savedObjectsClient, - readStream: createSavedObjectsStreamFromNdJson(request.payload.file), - retries: request.payload.retries, - objectLimit: request.server.config().get('savedObjects.maxImportExportSize'), - }); - }, -}); diff --git a/src/legacy/server/saved_objects/routes/update.ts b/src/legacy/server/saved_objects/routes/update.ts deleted file mode 100644 index dadd1e412c8cd..0000000000000 --- a/src/legacy/server/saved_objects/routes/update.ts +++ /dev/null @@ -1,75 +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 Hapi from 'hapi'; -import Joi from 'joi'; -import { SavedObjectAttributes, SavedObjectsClient } from 'src/core/server'; -import { Prerequisites, SavedObjectReference } from './types'; - -interface UpdateRequest extends Hapi.Request { - pre: { - savedObjectsClient: SavedObjectsClient; - }; - params: { - type: string; - id: string; - }; - payload: { - attributes: SavedObjectAttributes; - version?: string; - references: SavedObjectReference[]; - }; -} - -export const createUpdateRoute = (prereqs: Prerequisites) => { - return { - path: '/api/saved_objects/{type}/{id}', - method: 'PUT', - config: { - pre: [prereqs.getSavedObjectsClient], - validate: { - params: Joi.object() - .keys({ - type: Joi.string().required(), - id: Joi.string().required(), - }) - .required(), - payload: Joi.object({ - attributes: Joi.object().required(), - version: Joi.string(), - references: Joi.array().items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ), - }).required(), - }, - handler(request: UpdateRequest) { - const { savedObjectsClient } = request.pre; - const { type, id } = request.params; - const { attributes, version, references } = request.payload; - const options = { version, references }; - - return savedObjectsClient.update(type, id, attributes, options); - }, - }, - }; -}; diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.js b/src/legacy/server/saved_objects/saved_objects_mixin.js index 1d2f2d0f23e17..c7286f77ceccd 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.js @@ -31,30 +31,6 @@ import { getRootPropertiesObjects } from '../../../core/server/saved_objects/map import { convertTypesToLegacySchema } from '../../../core/server/saved_objects/utils'; import { SavedObjectsManagement } from '../../../core/server/saved_objects/management'; -import { - createBulkCreateRoute, - createBulkGetRoute, - createCreateRoute, - createDeleteRoute, - createFindRoute, - createGetRoute, - createUpdateRoute, - createBulkUpdateRoute, - createExportRoute, - createImportRoute, - createResolveImportErrorsRoute, - createLogLegacyImportRoute, -} from './routes'; - -function getImportableAndExportableTypes({ kbnServer, visibleTypes }) { - const { savedObjectsManagement = {} } = kbnServer.uiExports; - return visibleTypes.filter( - type => - savedObjectsManagement[type] && - savedObjectsManagement[type].isImportableAndExportable === true - ); -} - export function savedObjectsMixin(kbnServer, server) { const migrator = kbnServer.newPlatform.__internals.kibanaMigrator; const typeRegistry = kbnServer.newPlatform.__internals.typeRegistry; @@ -62,7 +38,6 @@ export function savedObjectsMixin(kbnServer, server) { const allTypes = Object.keys(getRootPropertiesObjects(mappings)); const schema = new SavedObjectsSchema(convertTypesToLegacySchema(typeRegistry.getAllTypes())); const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type)); - const importableAndExportableTypes = getImportableAndExportableTypes({ kbnServer, visibleTypes }); server.decorate('server', 'kibanaMigrator', migrator); server.decorate( @@ -79,27 +54,6 @@ export function savedObjectsMixin(kbnServer, server) { return; } - const prereqs = { - getSavedObjectsClient: { - assign: 'savedObjectsClient', - method(req) { - return req.getSavedObjectsClient(); - }, - }, - }; - server.route(createBulkCreateRoute(prereqs)); - server.route(createBulkGetRoute(prereqs)); - server.route(createBulkUpdateRoute(prereqs)); - server.route(createCreateRoute(prereqs)); - server.route(createDeleteRoute(prereqs)); - server.route(createFindRoute(prereqs)); - server.route(createGetRoute(prereqs)); - server.route(createUpdateRoute(prereqs)); - server.route(createExportRoute(prereqs, server, importableAndExportableTypes)); - server.route(createImportRoute(prereqs, server, importableAndExportableTypes)); - server.route(createResolveImportErrorsRoute(prereqs, server, importableAndExportableTypes)); - server.route(createLogLegacyImportRoute()); - const serializer = kbnServer.newPlatform.start.core.savedObjects.createSerializer(); const createRepository = (callCluster, extraTypes = []) => { diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.test.js b/src/legacy/server/saved_objects/saved_objects_mixin.test.js index 9ca0374b959f6..99d2041448426 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.test.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.test.js @@ -185,82 +185,6 @@ describe('Saved Objects Mixin', () => { }); }); - describe('Routes', () => { - it('should create 12 routes', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledTimes(12); - }); - it('should add POST /api/saved_objects/_bulk_create', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_bulk_create', method: 'POST' }) - ); - }); - it('should add POST /api/saved_objects/_bulk_get', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_bulk_get', method: 'POST' }) - ); - }); - it('should add POST /api/saved_objects/{type}/{id?}', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/{type}/{id?}', method: 'POST' }) - ); - }); - it('should add DELETE /api/saved_objects/{type}/{id}', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/{type}/{id}', method: 'DELETE' }) - ); - }); - it('should add GET /api/saved_objects/_find', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_find', method: 'GET' }) - ); - }); - it('should add GET /api/saved_objects/{type}/{id}', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/{type}/{id}', method: 'GET' }) - ); - }); - it('should add PUT /api/saved_objects/{type}/{id}', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/{type}/{id}', method: 'PUT' }) - ); - }); - it('should add GET /api/saved_objects/_export', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_export', method: 'POST' }) - ); - }); - it('should add POST /api/saved_objects/_import', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_import', method: 'POST' }) - ); - }); - it('should add POST /api/saved_objects/_resolve_import_errors', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/api/saved_objects/_resolve_import_errors', - method: 'POST', - }) - ); - }); - it('should add POST /api/saved_objects/_log_legacy_import', () => { - savedObjectsMixin(mockKbnServer, mockServer); - expect(mockServer.route).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/saved_objects/_log_legacy_import', method: 'POST' }) - ); - }); - }); - describe('Saved object service', () => { let service; diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index 24c1648a8cd0f..af3662d13de4e 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -209,4 +209,34 @@ export class Executor = Record { + const initialState = this.state.get(); + const fork = new Executor(initialState); + + /** + * Synchronize registry state - make any new types, functions and context + * also available in the forked instance of `Executor`. + */ + this.state.state$.subscribe(({ types, functions, context }) => { + const state = fork.state.get(); + fork.state.set({ + ...state, + types: { + ...types, + ...state.types, + }, + functions: { + ...functions, + ...state.functions, + }, + context: { + ...context, + ...state.context, + }, + }); + }); + + return fork; + } } diff --git a/src/plugins/expressions/common/service/expressions_services.test.ts b/src/plugins/expressions/common/service/expressions_services.test.ts index c9687192481c6..6028e4a4cd1df 100644 --- a/src/plugins/expressions/common/service/expressions_services.test.ts +++ b/src/plugins/expressions/common/service/expressions_services.test.ts @@ -51,4 +51,83 @@ describe('ExpressionsService', () => { expect(typeof expressions.setup().getFunctions().var_set).toBe('object'); }); + + describe('.fork()', () => { + test('returns a new ExpressionsService instance', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + expect(fork).not.toBe(service); + expect(fork).toBeInstanceOf(ExpressionsService); + }); + + test('fork keeps all types of the origin service', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + expect(fork.executor.state.get().types).toEqual(service.executor.state.get().types); + }); + + test('fork keeps all functions of the origin service', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + expect(fork.executor.state.get().functions).toEqual(service.executor.state.get().functions); + }); + + test('fork keeps context of the origin service', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + expect(fork.executor.state.get().context).toEqual(service.executor.state.get().context); + }); + + test('newly registered functions in origin are also available in fork', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + service.registerFunction({ + name: '__test__', + args: {}, + help: '', + fn: () => {}, + }); + + expect(fork.executor.state.get().functions).toEqual(service.executor.state.get().functions); + }); + + test('newly registered functions in fork are NOT available in origin', () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + fork.registerFunction({ + name: '__test__', + args: {}, + help: '', + fn: () => {}, + }); + + expect(Object.values(fork.executor.state.get().functions)).toHaveLength( + Object.values(service.executor.state.get().functions).length + 1 + ); + }); + + test('fork can execute an expression with newly registered function', async () => { + const service = new ExpressionsService(); + const fork = service.fork(); + + service.registerFunction({ + name: '__test__', + args: {}, + help: '', + fn: () => { + return '123'; + }, + }); + + const result = await fork.run('__test__', null); + + expect(result).toBe('123'); + }); + }); }); diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts index 9663c05f0d7c2..94019aa62841e 100644 --- a/src/plugins/expressions/common/service/expressions_services.ts +++ b/src/plugins/expressions/common/service/expressions_services.ts @@ -25,6 +25,11 @@ import { ExecutionContract } from '../execution/execution_contract'; export type ExpressionsServiceSetup = ReturnType; export type ExpressionsServiceStart = ReturnType; +export interface ExpressionServiceParams { + executor?: Executor; + renderers?: ExpressionRendererRegistry; +} + /** * `ExpressionsService` class is used for multiple purposes: * @@ -45,8 +50,16 @@ export type ExpressionsServiceStart = ReturnType; * so that JSDoc appears in developers IDE when they use those `plugins.expressions.registerFunction(`. */ export class ExpressionsService { - public readonly executor = Executor.createWithDefaults(); - public readonly renderers = new ExpressionRendererRegistry(); + public readonly executor: Executor; + public readonly renderers: ExpressionRendererRegistry; + + constructor({ + executor = Executor.createWithDefaults(), + renderers = new ExpressionRendererRegistry(), + }: ExpressionServiceParams = {}) { + this.executor = executor; + this.renderers = renderers; + } /** * Register an expression function, which will be possible to execute as @@ -118,6 +131,23 @@ export class ExpressionsService { context?: ExtraContext ): Promise => this.executor.run(ast, input, context); + /** + * Create a new instance of `ExpressionsService`. The new instance inherits + * all state of the original `ExpressionsService`, including all expression + * types, expression functions and context. Also, all new types and functions + * registered in the original services AFTER the forking event will be + * available in the forked instance. However, all new types and functions + * registered in the forked instances will NOT be available to the original + * service. + */ + public readonly fork = (): ExpressionsService => { + const executor = this.executor.fork(); + const renderers = this.renderers; + const fork = new ExpressionsService({ executor, renderers }); + + return fork; + }; + /** * Starts expression execution and immediately returns `ExecutionContract` * instance that tracks the progress of the execution and can be used to @@ -139,7 +169,7 @@ export class ExpressionsService { }; public setup() { - const { executor, renderers, registerFunction, run } = this; + const { executor, renderers, registerFunction, run, fork } = this; const getFunction = executor.getFunction.bind(executor); const getFunctions = executor.getFunctions.bind(executor); @@ -151,6 +181,7 @@ export class ExpressionsService { const registerType = executor.registerType.bind(executor); return { + fork, getFunction, getFunctions, getRenderer, diff --git a/src/plugins/expressions/public/mocks.tsx b/src/plugins/expressions/public/mocks.tsx index 40ae698bc95eb..eabc4034e7430 100644 --- a/src/plugins/expressions/public/mocks.tsx +++ b/src/plugins/expressions/public/mocks.tsx @@ -31,6 +31,7 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { + fork: jest.fn(), getFunction: jest.fn(), getFunctions: jest.fn(), getRenderer: jest.fn(), diff --git a/src/plugins/expressions/public/plugin.test.ts b/src/plugins/expressions/public/plugin.test.ts index fdfd583eac9de..ac9a2f508e2db 100644 --- a/src/plugins/expressions/public/plugin.test.ts +++ b/src/plugins/expressions/public/plugin.test.ts @@ -19,6 +19,7 @@ import { expressionsPluginMock } from './mocks'; import { add } from '../common/test_helpers/expression_functions/add'; +import { ExpressionsService } from '../common'; describe('ExpressionsPublicPlugin', () => { test('can instantiate from mocks', async () => { @@ -27,6 +28,13 @@ describe('ExpressionsPublicPlugin', () => { }); describe('setup contract', () => { + test('.fork() method returns ExpressionsService', async () => { + const { setup } = await expressionsPluginMock.createPlugin(); + const fork = setup.fork(); + + expect(fork).toBeInstanceOf(ExpressionsService); + }); + describe('.registerFunction()', () => { test('can register a function', async () => { const { setup } = await expressionsPluginMock.createPlugin(); diff --git a/test/api_integration/apis/general/index.js b/test/api_integration/apis/general/index.js index dca47dddbd4c8..2420297fb1af9 100644 --- a/test/api_integration/apis/general/index.js +++ b/test/api_integration/apis/general/index.js @@ -21,6 +21,5 @@ export default function({ loadTestFile }) { describe('general', () => { loadTestFile(require.resolve('./cookies')); loadTestFile(require.resolve('./csp')); - loadTestFile(require.resolve('./prototype_pollution')); }); } diff --git a/test/api_integration/apis/general/prototype_pollution.ts b/test/api_integration/apis/general/prototype_pollution.ts deleted file mode 100644 index 3e74480ebe9eb..0000000000000 --- a/test/api_integration/apis/general/prototype_pollution.ts +++ /dev/null @@ -1,57 +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 { FtrProviderContext } from 'test/api_integration/ftr_provider_context'; - -// eslint-disable-next-line import/no-default-export -export default function({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - describe('prototype pollution smoke test', () => { - it('prevents payloads with the "constructor.prototype" pollution vector from being accepted', async () => { - await supertest - .post('/api/saved_objects/_log_legacy_import') - .send([ - { - constructor: { - prototype: 'foo', - }, - }, - ]) - .expect(400, { - statusCode: 400, - error: 'Bad Request', - message: "'constructor.prototype' is an invalid key", - validation: { source: 'payload', keys: [] }, - }); - }); - - it('prevents payloads with the "__proto__" pollution vector from being accepted', async () => { - await supertest - .post('/api/saved_objects/_log_legacy_import') - .send(JSON.parse(`{"foo": { "__proto__": {} } }`)) - .expect(400, { - statusCode: 400, - error: 'Bad Request', - message: "'__proto__' is an invalid key", - validation: { source: 'payload', keys: [] }, - }); - }); - }); -} diff --git a/test/api_integration/apis/saved_objects/export.js b/test/api_integration/apis/saved_objects/export.js index 6648ac7b90622..ace65f190dec2 100644 --- a/test/api_integration/apis/saved_objects/export.js +++ b/test/api_integration/apis/saved_objects/export.js @@ -192,12 +192,9 @@ export default function({ getService }) { statusCode: 400, error: 'Bad Request', message: - 'child "type" fails because ["type" at position 0 fails because ' + - '["0" must be one of [config, dashboard, index-pattern, query, search, url, visualization]]]', - validation: { - source: 'payload', - keys: ['type.0'], - }, + '[request body.type]: types that failed validation:\n' + + '- [request body.type.0]: expected value of type [string] but got [Array]\n' + + '- [request body.type.1.0]: wigwags is not exportable', }); }); }); @@ -215,8 +212,7 @@ export default function({ getService }) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: '"value" must be an object', - validation: { source: 'payload', keys: ['value'] }, + message: '[request body]: expected a plain object value, but found [null] instead.', }); }); }); @@ -421,8 +417,7 @@ export default function({ getService }) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: '"value" contains a conflict between exclusive peers [type, objects]', - validation: { source: 'payload', keys: ['value'] }, + message: `Can't specify both "types" and "objects" properties when exporting`, }); }); }); diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index 43c32156b6ada..54a19602fd414 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -230,12 +230,9 @@ export default function({ getService }) { .then(resp => { expect(resp.body).to.eql({ error: 'Bad Request', - message: 'child "type" fails because ["type" is required]', + message: + '[request query.type]: expected at least one defined value but got [undefined]', statusCode: 400, - validation: { - keys: ['type'], - source: 'query', - }, }); })); }); diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.js b/test/api_integration/apis/saved_objects/resolve_import_errors.js index 0375ad86b8f4a..60d2f42d51d13 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.js +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.js @@ -85,8 +85,7 @@ export default function({ getService }) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: 'child "file" fails because ["file" is required]', - validation: { source: 'payload', keys: ['file'] }, + message: '[request body.file]: expected value of type [Stream] but got [undefined]', }); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts index 9f21b4e3d53a1..28cc4a6e8827d 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts @@ -4,14 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - assertAtLeastOneEventMatchesSearch, - executeKQL, - hostExistsQuery, - toggleTimelineVisibility, -} from '../../lib/timeline/helpers'; -import { HOSTS_PAGE } from '../../lib/urls'; -import { loginAndWaitForPage } from '../../lib/util/helpers'; +import { SERVER_SIDE_EVENT_COUNT } from '../../../screens/timeline/main'; +import { HOSTS_PAGE } from '../../../urls/navigation'; +import { loginAndWaitForPage, DEFAULT_TIMEOUT } from '../../../tasks/login'; +import { openTimeline } from '../../../tasks/siem_main'; +import { executeTimelineKQL } from '../../../tasks/timeline/main'; describe('timeline search or filter KQL bar', () => { beforeEach(() => { @@ -19,10 +16,12 @@ describe('timeline search or filter KQL bar', () => { }); it('executes a KQL query', () => { - toggleTimelineVisibility(); + const hostExistsQuery = 'host.name: *'; + openTimeline(); + executeTimelineKQL(hostExistsQuery); - executeKQL(hostExistsQuery); - - assertAtLeastOneEventMatchesSearch(); + cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT }) + .invoke('text') + .should('be.above', 0); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/auto_sizer/__examples__/index.stories.tsx b/x-pack/legacy/plugins/siem/public/components/auto_sizer/__examples__/index.stories.tsx deleted file mode 100644 index 414cea0d3f40d..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/auto_sizer/__examples__/index.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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. - */ -import { storiesOf } from '@storybook/react'; -import React from 'react'; -import { AutoSizer } from '..'; - -storiesOf('components/AutoSizer', module).add('example', () => ( -
- - {({ measureRef, content }) => ( -
-
- {'width: '} - {content.width} -
-
- {'height: '} - {content.height} -
-
- )} -
-
-)); diff --git a/x-pack/legacy/plugins/siem/public/components/auto_sizer/index.tsx b/x-pack/legacy/plugins/siem/public/components/auto_sizer/index.tsx deleted file mode 100644 index 8b3a85b28b8fe..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/auto_sizer/index.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * 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. - */ - -import isEqual from 'lodash/fp/isEqual'; -import React from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; - -interface Measurement { - width?: number; - height?: number; -} - -interface Measurements { - bounds: Measurement; - content: Measurement; - windowMeasurement: Measurement; -} - -interface AutoSizerProps { - detectAnyWindowResize?: boolean; - bounds?: boolean; - content?: boolean; - onResize?: (size: Measurements) => void; - children: ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: { measureRef: (instance: HTMLElement | null) => any } & Measurements - ) => React.ReactNode; -} - -interface AutoSizerState { - boundsMeasurement: Measurement; - contentMeasurement: Measurement; - windowMeasurement: Measurement; -} - -/** A hard-fork of the `infra` `AutoSizer` ಠ_ಠ */ -export class AutoSizer extends React.PureComponent { - public element: HTMLElement | null = null; - public resizeObserver: ResizeObserver | null = null; - public windowWidth: number = -1; - - public readonly state = { - boundsMeasurement: { - height: void 0, - width: void 0, - }, - contentMeasurement: { - height: void 0, - width: void 0, - }, - windowMeasurement: { - height: void 0, - width: void 0, - }, - }; - - constructor(props: AutoSizerProps) { - super(props); - if (this.props.detectAnyWindowResize) { - window.addEventListener('resize', this.updateMeasurement); - } - this.resizeObserver = new ResizeObserver(entries => { - entries.forEach(entry => { - if (entry.target === this.element) { - this.measure(entry); - } - }); - }); - } - - public componentWillUnmount() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - if (this.props.detectAnyWindowResize) { - window.removeEventListener('resize', this.updateMeasurement); - } - } - - public measure = (entry: ResizeObserverEntry | null) => { - if (!this.element) { - return; - } - - const { content = true, bounds = false } = this.props; - const { - boundsMeasurement: previousBoundsMeasurement, - contentMeasurement: previousContentMeasurement, - windowMeasurement: previousWindowMeasurement, - } = this.state; - - const boundsRect = bounds ? this.element.getBoundingClientRect() : null; - const boundsMeasurement = boundsRect - ? { - height: this.element.getBoundingClientRect().height, - width: this.element.getBoundingClientRect().width, - } - : previousBoundsMeasurement; - const windowMeasurement: Measurement = { - width: window.innerWidth, - height: window.innerHeight, - }; - - if ( - this.props.detectAnyWindowResize && - boundsMeasurement && - boundsMeasurement.width && - this.windowWidth !== -1 && - this.windowWidth > window.innerWidth - ) { - const gap = this.windowWidth - window.innerWidth; - boundsMeasurement.width = boundsMeasurement.width - gap; - } - this.windowWidth = window.innerWidth; - const contentRect = content && entry ? entry.contentRect : null; - const contentMeasurement = - contentRect && entry - ? { - height: entry.contentRect.height, - width: entry.contentRect.width, - } - : previousContentMeasurement; - - if ( - isEqual(boundsMeasurement, previousBoundsMeasurement) && - isEqual(contentMeasurement, previousContentMeasurement) && - isEqual(windowMeasurement, previousWindowMeasurement) - ) { - return; - } - - requestAnimationFrame(() => { - if (!this.resizeObserver) { - return; - } - - this.setState({ boundsMeasurement, contentMeasurement, windowMeasurement }); - - if (this.props.onResize) { - this.props.onResize({ - bounds: boundsMeasurement, - content: contentMeasurement, - windowMeasurement, - }); - } - }); - }; - - public render() { - const { children } = this.props; - const { boundsMeasurement, contentMeasurement, windowMeasurement } = this.state; - - return children({ - bounds: boundsMeasurement, - content: contentMeasurement, - windowMeasurement, - measureRef: this.storeRef, - }); - } - - private updateMeasurement = () => { - window.setTimeout(() => { - this.measure(null); - }, 0); - }; - - private storeRef = (element: HTMLElement | null) => { - if (this.element && this.resizeObserver) { - this.resizeObserver.unobserve(this.element); - } - - if (element && this.resizeObserver) { - this.resizeObserver.observe(element); - } - - this.element = element; - }; -} diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx index 342d7d35f9cb7..27f0222b96b77 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx @@ -331,7 +331,7 @@ describe('AreaChart', () => { }); it(`should render area chart`, () => { - expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); + expect(shallowWrapper.find('WrappedByAutoSizer')).toHaveLength(1); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(0); }); }); @@ -344,7 +344,7 @@ describe('AreaChart', () => { }); it(`should render a chart place holder`, () => { - expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); + expect(shallowWrapper.find('WrappedByAutoSizer')).toHaveLength(0); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(1); }); } diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx index 57f78080abc60..fd05b80e41235 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx @@ -16,7 +16,8 @@ import { RecursivePartial, } from '@elastic/charts'; import { getOr, get, isNull, isNumber } from 'lodash/fp'; -import { AutoSizer } from '../auto_sizer'; +import useResizeObserver from 'use-resize-observer'; + import { ChartPlaceHolder } from './chart_place_holder'; import { useTimeZone } from '../../lib/kibana'; import { @@ -124,35 +125,24 @@ export const AreaChartBase = React.memo(AreaChartBaseComponent); AreaChartBase.displayName = 'AreaChartBase'; -export const AreaChartComponent = ({ - areaChart, - configs, -}: { +interface AreaChartComponentProps { areaChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; -}) => { +} + +export const AreaChartComponent: React.FC = ({ areaChart, configs }) => { + const { ref: measureRef, width, height } = useResizeObserver({}); const customHeight = get('customHeight', configs); const customWidth = get('customWidth', configs); + const chartHeight = getChartHeight(customHeight, height); + const chartWidth = getChartWidth(customWidth, width); return checkIfAnyValidSeriesExist(areaChart) ? ( - - {({ measureRef, content: { height, width } }) => ( - - - - )} - + + + ) : ( - + ); }; diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx index d8e5079dd72a6..0b6635b04d380 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx @@ -278,7 +278,7 @@ describe.each(chartDataSets)('BarChart with valid data [%o]', data => { }); it(`should render chart`, () => { - expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); + expect(shallowWrapper.find('WrappedByAutoSizer')).toHaveLength(1); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(0); }); }); @@ -290,8 +290,8 @@ describe.each(chartHolderDataSets)('BarChart with invalid data [%o]', data => { shallowWrapper = shallow(); }); - it(`should render chart holder`, () => { - expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); + it(`should render a ChartPlaceHolder`, () => { + expect(shallowWrapper.find('WrappedByAutoSizer')).toHaveLength(0); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(1); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx index d9dd302dae724..1355926d343df 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx @@ -8,9 +8,9 @@ import React from 'react'; import { Chart, BarSeries, Axis, Position, ScaleType, Settings } from '@elastic/charts'; import { getOr, get, isNumber } from 'lodash/fp'; import deepmerge from 'deepmerge'; +import useResizeObserver from 'use-resize-observer'; import { useTimeZone } from '../../lib/kibana'; -import { AutoSizer } from '../auto_sizer'; import { ChartPlaceHolder } from './chart_place_holder'; import { chartDefaultSettings, @@ -99,40 +99,25 @@ export const BarChartBase = React.memo(BarChartBaseComponent); BarChartBase.displayName = 'BarChartBase'; -export const BarChartComponent = ({ - barChart, - configs, -}: { +interface BarChartComponentProps { barChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; -}) => { +} + +export const BarChartComponent: React.FC = ({ barChart, configs }) => { + const { ref: measureRef, width, height } = useResizeObserver({}); const customHeight = get('customHeight', configs); const customWidth = get('customWidth', configs); + const chartHeight = getChartHeight(customHeight, height); + const chartWidth = getChartWidth(customWidth, width); return checkIfAnyValidSeriesExist(barChart) ? ( - - {({ measureRef, content: { height, width } }) => ( - - - - )} - + + + ) : ( - + ); }; -BarChartComponent.displayName = 'BarChartComponent'; - export const BarChart = React.memo(BarChartComponent); - -BarChart.displayName = 'BarChart'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx index 3cef3e98c2f0a..8c4228b597dbb 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import useResizeObserver from 'use-resize-observer'; import { mockIndexPattern, TestProviders } from '../../mock'; import { wait } from '../../lib/helpers'; @@ -27,6 +28,10 @@ mockUseFetchIndexPatterns.mockImplementation(() => [ }, ]); +const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; +jest.mock('use-resize-observer'); +mockUseResizeObserver.mockImplementation(() => ({})); + const from = 1566943856794; const to = 1566857456791; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index 14473605a7c88..cbce1f635310a 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -9,13 +9,13 @@ import deepEqual from 'fast-deep-equal'; import { getOr, isEmpty, isEqual, union } from 'lodash/fp'; import React, { useMemo } from 'react'; import styled from 'styled-components'; +import useResizeObserver from 'use-resize-observer'; import { BrowserFields } from '../../containers/source'; import { TimelineQuery } from '../../containers/timeline'; import { Direction } from '../../graphql/types'; import { useKibana } from '../../lib/kibana'; import { KqlMode } from '../../store/timeline/model'; -import { AutoSizer } from '../auto_sizer'; import { HeaderSection } from '../header_section'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; @@ -95,6 +95,7 @@ const EventsViewerComponent: React.FC = ({ toggleColumn, utilityBar, }) => { + const { ref: measureRef, width = 0 } = useResizeObserver({}); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const combinedQueries = combineQueries({ @@ -120,115 +121,108 @@ const EventsViewerComponent: React.FC = ({ return ( - - {({ measureRef, content: { width = 0 } }) => ( - <> - -
- - - {combinedQueries != null ? ( - - {({ - events, - getUpdatedAt, - inspect, - loading, - loadMore, - pageInfo, - refetch, - totalCount = 0, - }) => { - const totalCountMinusDeleted = - totalCount > 0 ? totalCount - deletedEventIds.length : 0; - - const subtitle = `${ - i18n.SHOWING - }: ${totalCountMinusDeleted.toLocaleString()} ${timelineTypeContext.unit?.( - totalCountMinusDeleted - ) ?? i18n.UNIT(totalCountMinusDeleted)}`; - - // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt) - return ( - <> - + +
+ + + {combinedQueries != null ? ( + + {({ + events, + getUpdatedAt, + inspect, + loading, + loadMore, + pageInfo, + refetch, + totalCount = 0, + }) => { + const totalCountMinusDeleted = + totalCount > 0 ? totalCount - deletedEventIds.length : 0; + + const subtitle = `${ + i18n.SHOWING + }: ${totalCountMinusDeleted.toLocaleString()} ${timelineTypeContext.unit?.( + totalCountMinusDeleted + ) ?? i18n.UNIT(totalCountMinusDeleted)}`; + + // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt) + return ( + <> + + {headerFilterGroup} + + + {utilityBar?.(refetch, totalCountMinusDeleted)} + +
+ + - {headerFilterGroup} - - - {utilityBar?.(refetch, totalCountMinusDeleted)} - -
- - - - !deletedEventIds.includes(e._id))} - id={id} - isEventViewer={true} - height={height} - sort={sort} - toggleColumn={toggleColumn} - /> - -
- -
- - ); - }} - - ) : null} - - )} - + inputId="global" + inspect={inspect} + loading={loading} + refetch={refetch} + /> + + !deletedEventIds.includes(e._id))} + id={id} + isEventViewer={true} + height={height} + sort={sort} + toggleColumn={toggleColumn} + /> + +
+ +
+ + ); + }} +
+ ) : null} + ); }; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx index ec8d329f1dfe3..2bedd1cb89b41 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import useResizeObserver from 'use-resize-observer'; import { wait } from '../../lib/helpers'; import { mockIndexPattern, TestProviders } from '../../mock'; @@ -26,6 +27,10 @@ mockUseFetchIndexPatterns.mockImplementation(() => [ }, ]); +const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; +jest.mock('use-resize-observer'); +mockUseResizeObserver.mockImplementation(() => ({})); + const from = 1566943856794; const to = 1566857456791; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap index 67d266d1cbf39..3fcd258b79147 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -1,10 +1,793 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Timeline rendering renders correctly against snapshot 1`] = ` - - - + + + + + + + + `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx index f7c0d0b475734..78899b7c5d628 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import useResizeObserver from 'use-resize-observer'; import { timelineQuery } from '../../containers/timeline/index.gql_query'; import { mockBrowserFields } from '../../containers/source/mock'; @@ -29,6 +30,10 @@ const testFlyoutHeight = 980; jest.mock('../../lib/kibana'); +const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; +jest.mock('use-resize-observer'); +mockUseResizeObserver.mockImplementation(() => ({})); + describe('Timeline', () => { const sort: Sort = { columnId: '@timestamp', diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx index 09457c8f0285a..4b7331ab14c7e 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx @@ -8,13 +8,13 @@ import { EuiFlexGroup } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; +import useResizeObserver from 'use-resize-observer'; import { BrowserFields } from '../../containers/source'; import { TimelineQuery } from '../../containers/timeline'; import { Direction } from '../../graphql/types'; import { useKibana } from '../../lib/kibana'; import { KqlMode, EventType } from '../../store/timeline/model'; -import { AutoSizer } from '../auto_sizer'; import { ColumnHeader } from './body/column_headers/column_header'; import { defaultHeaders } from './body/column_headers/default_headers'; import { Sort } from './body/sort'; @@ -88,7 +88,7 @@ interface Props { } /** The parent Timeline component */ -export const TimelineComponent = ({ +export const TimelineComponent: React.FC = ({ browserFields, columns, dataProviders, @@ -118,7 +118,10 @@ export const TimelineComponent = ({ start, sort, toggleColumn, -}: Props) => { +}) => { + const { ref: measureRef, width = 0, height: timelineHeaderHeight = 0 } = useResizeObserver< + HTMLDivElement + >({}); const kibana = useKibana(); const combinedQueries = combineQueries({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), @@ -132,101 +135,98 @@ export const TimelineComponent = ({ end, }); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; + return ( - - {({ measureRef, content: { height: timelineHeaderHeight = 0, width = 0 } }) => ( - + }> + + + + {combinedQueries != null ? ( + c.id)} + sourceId="default" + limit={itemsPerPage} + filterQuery={combinedQueries.filterQuery} + sortField={{ + sortFieldId: sort.columnId, + direction: sort.sortDirection as Direction, + }} > - - - - - {combinedQueries != null ? ( - c.id)} - sourceId="default" - limit={itemsPerPage} - filterQuery={combinedQueries.filterQuery} - sortField={{ - sortFieldId: sort.columnId, - direction: sort.sortDirection as Direction, - }} - > - {({ - events, - inspect, - loading, - totalCount, - pageInfo, - loadMore, - getUpdatedAt, - refetch, - }) => ( - - - -