add SavedObject export hooks (#87807)

* initial POC

* fix spaces UT

* address POC feedback, add tests for applyExportTransforms

* add sorting for transforms

* add type validation in SOTR

* add FTR tests

* update documentation

* add explicit so type export for client-side

* update generated doc

* add exporter test

* update license headers

* update generated doc

* fix so import... imports

* update generated doc

* nits

* update generated doc

* rename test plugins

* adding FTR tests on export failures
This commit is contained in:
Pierre Gayvallet 2021-01-21 15:27:28 +01:00 committed by GitHub
parent b3a9754394
commit 477d0bbe21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 2089 additions and 42 deletions

View file

@ -164,6 +164,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsExportByObjectOptions](./kibana-plugin-core-server.savedobjectsexportbyobjectoptions.md) | Options for the [export by objects API](./kibana-plugin-core-server.savedobjectsexporter.exportbyobjects.md) |
| [SavedObjectsExportByTypeOptions](./kibana-plugin-core-server.savedobjectsexportbytypeoptions.md) | Options for the [export by type API](./kibana-plugin-core-server.savedobjectsexporter.exportbytypes.md) |
| [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) | Structure of the export result details entry |
| [SavedObjectsExportTransformContext](./kibana-plugin-core-server.savedobjectsexporttransformcontext.md) | Context passed down to a [export transform function](./kibana-plugin-core-server.savedobjectsexporttransform.md) |
| [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) | |
| [SavedObjectsFindOptionsReference](./kibana-plugin-core-server.savedobjectsfindoptionsreference.md) | |
| [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) | Return type of the Saved Objects <code>find()</code> method.<!-- -->\*Note\*: this type is different between the Public and Server Saved Objects clients. |
@ -299,6 +300,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md) | Describes the factory used to create instances of the Saved Objects Client. |
| [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md)<!-- -->. |
| [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. |
| [SavedObjectsExportTransform](./kibana-plugin-core-server.savedobjectsexporttransform.md) | Transformation function used to mutate the exported objects of the associated type.<!-- -->A type's export transform function will be executed once per user-initiated export, for all objects of that type. |
| [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.<!-- -->Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation |
| [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.<!-- -->Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. |
| [SavedObjectsImportWarning](./kibana-plugin-core-server.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.<!-- -->See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-server.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-server.savedobjectsimportactionrequiredwarning.md) for more details. |

View file

@ -18,4 +18,5 @@ export interface SavedObjectExportBaseOptions
| [excludeExportDetails](./kibana-plugin-core-server.savedobjectexportbaseoptions.excludeexportdetails.md) | <code>boolean</code> | flag to not append [export details](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) to the end of the export stream. |
| [includeReferencesDeep](./kibana-plugin-core-server.savedobjectexportbaseoptions.includereferencesdeep.md) | <code>boolean</code> | flag to also include all related saved objects in the export stream. |
| [namespace](./kibana-plugin-core-server.savedobjectexportbaseoptions.namespace.md) | <code>string</code> | optional namespace to override the namespace used by the savedObjectsClient. |
| [request](./kibana-plugin-core-server.savedobjectexportbaseoptions.request.md) | <code>KibanaRequest</code> | The http request initiating the export. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectExportBaseOptions](./kibana-plugin-core-server.savedobjectexportbaseoptions.md) &gt; [request](./kibana-plugin-core-server.savedobjectexportbaseoptions.request.md)
## SavedObjectExportBaseOptions.request property
The http request initiating the export.
<b>Signature:</b>
```typescript
request: KibanaRequest;
```

View file

@ -9,8 +9,9 @@ Constructs a new instance of the `SavedObjectsExporter` class
<b>Signature:</b>
```typescript
constructor({ savedObjectsClient, exportSizeLimit, }: {
constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
});
```
@ -19,5 +20,5 @@ constructor({ savedObjectsClient, exportSizeLimit, }: {
| Parameter | Type | Description |
| --- | --- | --- |
| { savedObjectsClient, exportSizeLimit, } | <code>{</code><br/><code> savedObjectsClient: SavedObjectsClientContract;</code><br/><code> exportSizeLimit: number;</code><br/><code> }</code> | |
| { savedObjectsClient, typeRegistry, exportSizeLimit, } | <code>{</code><br/><code> savedObjectsClient: SavedObjectsClientContract;</code><br/><code> typeRegistry: ISavedObjectTypeRegistry;</code><br/><code> exportSizeLimit: number;</code><br/><code> }</code> | |

View file

@ -15,7 +15,7 @@ export declare class SavedObjectsExporter
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)({ savedObjectsClient, exportSizeLimit, })](./kibana-plugin-core-server.savedobjectsexporter._constructor_.md) | | Constructs a new instance of the <code>SavedObjectsExporter</code> class |
| [(constructor)({ savedObjectsClient, typeRegistry, exportSizeLimit, })](./kibana-plugin-core-server.savedobjectsexporter._constructor_.md) | | Constructs a new instance of the <code>SavedObjectsExporter</code> class |
## Properties

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsExportError](./kibana-plugin-core-server.savedobjectsexporterror.md) &gt; [invalidTransformError](./kibana-plugin-core-server.savedobjectsexporterror.invalidtransformerror.md)
## SavedObjectsExportError.invalidTransformError() method
Error returned when a [export tranform](./kibana-plugin-core-server.savedobjectsexporttransform.md) performed an invalid operation during the transform, such as removing objects from the export, or changing an object's type or id.
<b>Signature:</b>
```typescript
static invalidTransformError(objectKeys: string[]): SavedObjectsExportError;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| objectKeys | <code>string[]</code> | |
<b>Returns:</b>
`SavedObjectsExportError`

View file

@ -29,5 +29,7 @@ export declare class SavedObjectsExportError extends Error
| Method | Modifiers | Description |
| --- | --- | --- |
| [exportSizeExceeded(limit)](./kibana-plugin-core-server.savedobjectsexporterror.exportsizeexceeded.md) | <code>static</code> | |
| [invalidTransformError(objectKeys)](./kibana-plugin-core-server.savedobjectsexporterror.invalidtransformerror.md) | <code>static</code> | Error returned when a [export tranform](./kibana-plugin-core-server.savedobjectsexporttransform.md) performed an invalid operation during the transform, such as removing objects from the export, or changing an object's type or id. |
| [objectFetchError(objects)](./kibana-plugin-core-server.savedobjectsexporterror.objectfetcherror.md) | <code>static</code> | |
| [objectTransformError(objects, cause)](./kibana-plugin-core-server.savedobjectsexporterror.objecttransformerror.md) | <code>static</code> | Error returned when a [export tranform](./kibana-plugin-core-server.savedobjectsexporttransform.md) threw an error |

View file

@ -0,0 +1,25 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsExportError](./kibana-plugin-core-server.savedobjectsexporterror.md) &gt; [objectTransformError](./kibana-plugin-core-server.savedobjectsexporterror.objecttransformerror.md)
## SavedObjectsExportError.objectTransformError() method
Error returned when a [export tranform](./kibana-plugin-core-server.savedobjectsexporttransform.md) threw an error
<b>Signature:</b>
```typescript
static objectTransformError(objects: SavedObject[], cause: Error): SavedObjectsExportError;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| objects | <code>SavedObject[]</code> | |
| cause | <code>Error</code> | |
<b>Returns:</b>
`SavedObjectsExportError`

View file

@ -0,0 +1,86 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsExportTransform](./kibana-plugin-core-server.savedobjectsexporttransform.md)
## SavedObjectsExportTransform type
Transformation function used to mutate the exported objects of the associated type.
A type's export transform function will be executed once per user-initiated export, for all objects of that type.
<b>Signature:</b>
```typescript
export declare type SavedObjectsExportTransform = <T = unknown>(context: SavedObjectsExportTransformContext, objects: Array<SavedObject<T>>) => SavedObject[] | Promise<SavedObject[]>;
```
## Remarks
Trying to change an object's id or type during the transform will result in a runtime error during the export process.
## Example 1
Registering a transform function changing the object's attributes during the export
```ts
// src/plugins/my_plugin/server/plugin.ts
import { myType } from './saved_objects';
export class Plugin() {
setup: (core: CoreSetup) => {
core.savedObjects.registerType({
...myType,
management: {
...myType.management,
onExport: (ctx, objects) => {
return objects.map((obj) => ({
...obj,
attributes: {
...obj.attributes,
enabled: false,
}
})
}
},
});
}
}
```
## Example 2
Registering a transform function adding additional objects to the export
```ts
// src/plugins/my_plugin/server/plugin.ts
import { myType } from './saved_objects';
export class Plugin() {
setup: (core: CoreSetup) => {
const savedObjectStartContractPromise = getStartServices().then(
([{ savedObjects: savedObjectsStart }]) => savedObjectsStart
);
core.savedObjects.registerType({
...myType,
management: {
...myType.management,
onExport: async (ctx, objects) => {
const { getScopedClient } = await savedObjectStartContractPromise;
const client = getScopedClient(ctx.request);
const depResponse = await client.find({
type: 'my-nested-object',
hasReference: objs.map(({ id, type }) => ({ id, type })),
});
return [...objs, ...depResponse.saved_objects];
}
},
});
}
}
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsExportTransformContext](./kibana-plugin-core-server.savedobjectsexporttransformcontext.md)
## SavedObjectsExportTransformContext interface
Context passed down to a [export transform function](./kibana-plugin-core-server.savedobjectsexporttransform.md)
<b>Signature:</b>
```typescript
export interface SavedObjectsExportTransformContext
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [request](./kibana-plugin-core-server.savedobjectsexporttransformcontext.request.md) | <code>KibanaRequest</code> | The request that initiated the export request. Can be used to create scoped services or client inside the [transformation](./kibana-plugin-core-server.savedobjectsexporttransform.md) |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsExportTransformContext](./kibana-plugin-core-server.savedobjectsexporttransformcontext.md) &gt; [request](./kibana-plugin-core-server.savedobjectsexporttransformcontext.request.md)
## SavedObjectsExportTransformContext.request property
The request that initiated the export request. Can be used to create scoped services or client inside the [transformation](./kibana-plugin-core-server.savedobjectsexporttransform.md)
<b>Signature:</b>
```typescript
request: KibanaRequest;
```

View file

@ -22,5 +22,6 @@ export interface SavedObjectsTypeManagementDefinition
| [getTitle](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.gettitle.md) | <code>(savedObject: SavedObject&lt;any&gt;) =&gt; string</code> | Function returning the title to display in the management table. If not defined, will use the object's type and id to generate a label. |
| [icon](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.icon.md) | <code>string</code> | The eui icon name to display in the management table. If not defined, the default icon will be used. |
| [importableAndExportable](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.importableandexportable.md) | <code>boolean</code> | Is the type importable or exportable. Defaults to <code>false</code>. |
| [onExport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md) | <code>SavedObjectsExportTransform</code> | An optional export transform function that can be used transform the objects of the registered type during the export process.<!-- -->It can be used to either mutate the exported objects, or add additional objects (of any type) to the export list.<!-- -->See [the transform type documentation](./kibana-plugin-core-server.savedobjectsexporttransform.md) for more info and examples. |
| [onImport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onimport.md) | <code>SavedObjectsImportHook</code> | An optional [import hook](./kibana-plugin-core-server.savedobjectsimporthook.md) to use when importing given type.<!-- -->Import hooks are executed during the savedObjects import process and allow to interact with the imported objects. See the [hook documentation](./kibana-plugin-core-server.savedobjectsimporthook.md) for more info. |

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsTypeManagementDefinition](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) &gt; [onExport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md)
## SavedObjectsTypeManagementDefinition.onExport property
An optional export transform function that can be used transform the objects of the registered type during the export process.
It can be used to either mutate the exported objects, or add additional objects (of any type) to the export list.
See [the transform type documentation](./kibana-plugin-core-server.savedobjectsexporttransform.md) for more info and examples.
<b>Signature:</b>
```typescript
onExport?: SavedObjectsExportTransform;
```
## Remarks
`importableAndExportable` must be `true` to specify this property.

View file

@ -14,6 +14,10 @@ Import hooks are executed during the savedObjects import process and allow to in
onImport?: SavedObjectsImportHook;
```
## Remarks
`importableAndExportable` must be `true` to specify this property.
## Example
Registering a hook displaying a warning about a specific type of object
@ -48,5 +52,4 @@ export class Plugin() {
}
```
messages returned in the warnings are user facing and must be translated.

View file

@ -9,6 +9,7 @@ import { ApiResponse } from '@elastic/elasticsearch/lib/Transport';
import Boom from '@hapi/boom';
import { ConfigDeprecationProvider } from '@kbn/config';
import { ConfigPath } from '@kbn/config';
import { DetailedPeerCertificate } from 'tls';
import { EnvironmentMode } from '@kbn/config';
import { EuiBreadcrumb } from '@elastic/eui';
import { EuiButtonEmptyProps } from '@elastic/eui';
@ -18,20 +19,25 @@ import { EuiGlobalToastListToast } from '@elastic/eui';
import { History } from 'history';
import { Href } from 'history';
import { IconType } from '@elastic/eui';
import { IncomingHttpHeaders } from 'http';
import { KibanaClient } from '@elastic/elasticsearch/api/kibana';
import { Location } from 'history';
import { LocationDescriptorObject } from 'history';
import { Logger } from '@kbn/logging';
import { LogMeta } from '@kbn/logging';
import { MaybePromise } from '@kbn/utility-types';
import { ObjectType } from '@kbn/config-schema';
import { Observable } from 'rxjs';
import { PackageInfo } from '@kbn/config';
import { Path } from 'history';
import { PeerCertificate } from 'tls';
import { PublicMethodsOf } from '@kbn/utility-types';
import { PublicUiSettingsParams as PublicUiSettingsParams_2 } from 'src/core/server/types';
import React from 'react';
import { RecursiveReadonly } from '@kbn/utility-types';
import { Request } from '@hapi/hapi';
import * as Rx from 'rxjs';
import { SchemaTypeError } from '@kbn/config-schema';
import { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport';
import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport';
import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport';
@ -39,6 +45,7 @@ import { Type } from '@kbn/config-schema';
import { TypeOf } from '@kbn/config-schema';
import { UiCounterMetricType } from '@kbn/analytics';
import { UnregisterCallback } from 'history';
import { URL } from 'url';
import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/types';
// @internal (undocumented)

View file

@ -320,6 +320,8 @@ export {
SavedObjectsExportByObjectOptions,
SavedObjectsExportByTypeOptions,
SavedObjectsExportError,
SavedObjectsExportTransform,
SavedObjectsExportTransformContext,
SavedObjectsImporter,
ISavedObjectsImporter,
SavedObjectsImportError,

View file

@ -0,0 +1,300 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { SavedObject } from '../../../types';
import { KibanaRequest } from '../../http';
import { httpServerMock } from '../../http/http_server.mocks';
import { applyExportTransforms } from './apply_export_transforms';
import { SavedObjectsExportTransform } from './types';
const createObj = (
type: string,
id: string,
attributes: Record<string, any> = {}
): SavedObject => ({
type,
id,
attributes,
references: [],
});
const createTransform = (
implementation: SavedObjectsExportTransform = (ctx, objs) => objs
): jest.MockedFunction<SavedObjectsExportTransform> => jest.fn(implementation);
const expectedContext = {
request: expect.any(KibanaRequest),
};
describe('applyExportTransforms', () => {
let request: ReturnType<typeof httpServerMock.createKibanaRequest>;
beforeEach(() => {
request = httpServerMock.createKibanaRequest();
});
it('calls the transform functions with the correct parameters', async () => {
const foo1 = createObj('foo', '1');
const foo2 = createObj('foo', '2');
const bar1 = createObj('bar', '1');
const fooTransform = createTransform();
const barTransform = createTransform();
await applyExportTransforms({
request,
objects: [foo1, bar1, foo2],
transforms: {
foo: fooTransform,
bar: barTransform,
},
});
expect(fooTransform).toHaveBeenCalledTimes(1);
expect(fooTransform).toHaveBeenCalledWith(expectedContext, [foo1, foo2]);
expect(barTransform).toHaveBeenCalledTimes(1);
expect(barTransform).toHaveBeenCalledWith(expectedContext, [bar1]);
});
it('does not call the transform functions if no objects are present', async () => {
const foo1 = createObj('foo', '1');
const fooTransform = createTransform();
const barTransform = createTransform();
await applyExportTransforms({
request,
objects: [foo1],
transforms: {
foo: fooTransform,
bar: barTransform,
},
});
expect(fooTransform).toHaveBeenCalledTimes(1);
expect(fooTransform).toHaveBeenCalledWith(expectedContext, [foo1]);
expect(barTransform).not.toHaveBeenCalled();
});
it('allows to add objects to the export', async () => {
const foo1 = createObj('foo', '1');
const foo2 = createObj('foo', '2');
const bar1 = createObj('bar', '1');
const dolly1 = createObj('dolly', '1');
const hello1 = createObj('hello', '1');
const fooTransform = createTransform((ctx, objs) => {
return [...objs, dolly1];
});
const barTransform = createTransform((ctx, objs) => {
return [...objs, hello1];
});
const result = await applyExportTransforms({
request,
objects: [foo1, bar1, foo2],
transforms: {
foo: fooTransform,
bar: barTransform,
},
});
expect(result).toEqual([foo1, foo2, dolly1, bar1, hello1]);
});
it('returns unmutated objects if no transform is defined for the type', async () => {
const foo1 = createObj('foo', '1');
const foo2 = createObj('foo', '2');
const bar1 = createObj('bar', '1');
const bar2 = createObj('bar', '2');
const dolly1 = createObj('dolly', '1');
const fooTransform = createTransform((ctx, objs) => {
return [...objs, dolly1];
});
const result = await applyExportTransforms({
request,
objects: [foo1, foo2, bar1, bar2],
transforms: {
foo: fooTransform,
},
});
expect(result).toEqual([foo1, foo2, dolly1, bar1, bar2]);
});
it('allows to mutate objects', async () => {
const foo1 = createObj('foo', '1', { enabled: true });
const foo2 = createObj('foo', '2', { enabled: true });
const disableFoo = (obj: SavedObject<any>) => ({
...obj,
attributes: {
...obj.attributes,
enabled: false,
},
});
const fooTransform = createTransform((ctx, objs) => {
return objs.map(disableFoo);
});
const result = await applyExportTransforms({
request,
objects: [foo1, foo2],
transforms: {
foo: fooTransform,
},
});
expect(result).toEqual([foo1, foo2].map(disableFoo));
});
it('supports async transforms', async () => {
const foo1 = createObj('foo', '1');
const bar1 = createObj('bar', '1');
const dolly1 = createObj('dolly', '1');
const hello1 = createObj('hello', '1');
const fooTransform = createTransform((ctx, objs) => {
return Promise.resolve([...objs, dolly1]);
});
const barTransform = createTransform((ctx, objs) => {
return [...objs, hello1];
});
const result = await applyExportTransforms({
request,
objects: [foo1, bar1],
transforms: {
foo: fooTransform,
bar: barTransform,
},
});
expect(result).toEqual([foo1, dolly1, bar1, hello1]);
});
it('uses the provided sortFunction when provided', async () => {
const foo1 = createObj('foo', 'A');
const bar1 = createObj('bar', 'B');
const dolly1 = createObj('dolly', 'C');
const hello1 = createObj('hello', 'D');
const fooTransform = createTransform((ctx, objs) => {
return [...objs, dolly1];
});
const barTransform = createTransform((ctx, objs) => {
return [...objs, hello1];
});
const result = await applyExportTransforms({
request,
objects: [foo1, bar1],
transforms: {
foo: fooTransform,
bar: barTransform,
},
sortFunction: (obj1, obj2) => (obj1.id > obj2.id ? 1 : -1),
});
expect(result).toEqual([foo1, bar1, dolly1, hello1]);
});
it('throws when removing objects', async () => {
const foo1 = createObj('foo', '1', { enabled: true });
const foo2 = createObj('foo', '2', { enabled: true });
const fooTransform = createTransform((ctx, objs) => {
return [objs[0]];
});
await expect(
applyExportTransforms({
request,
objects: [foo1, foo2],
transforms: {
foo: fooTransform,
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
);
});
it('throws when changing the object type', async () => {
const foo1 = createObj('foo', '1', { enabled: true });
const foo2 = createObj('foo', '2', { enabled: true });
const fooTransform = createTransform((ctx, objs) => {
return objs.map((obj) => ({
...obj,
type: 'mutated',
}));
});
await expect(
applyExportTransforms({
request,
objects: [foo1, foo2],
transforms: {
foo: fooTransform,
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
);
});
it('throws when changing the object id', async () => {
const foo1 = createObj('foo', '1', { enabled: true });
const foo2 = createObj('foo', '2', { enabled: true });
const fooTransform = createTransform((ctx, objs) => {
return objs.map((obj, idx) => ({
...obj,
id: `mutated-${idx}`,
}));
});
await expect(
applyExportTransforms({
request,
objects: [foo1, foo2],
transforms: {
foo: fooTransform,
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
);
});
it('throws if the transform function throws', async () => {
const foo1 = createObj('foo', '1');
const fooTransform = createTransform(() => {
throw new Error('oups.');
});
await expect(
applyExportTransforms({
request,
objects: [foo1],
transforms: {
foo: fooTransform,
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Error transforming objects to export"`);
});
});

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { SavedObject } from '../../../types';
import { KibanaRequest } from '../../http';
import { SavedObjectsExportError } from './errors';
import { SavedObjectsExportTransform, SavedObjectsExportTransformContext } from './types';
import { getObjKey, SavedObjectComparator } from './utils';
interface ApplyExportTransformsOptions {
objects: SavedObject[];
request: KibanaRequest;
transforms: Record<string, SavedObjectsExportTransform>;
sortFunction?: SavedObjectComparator;
}
export const applyExportTransforms = async ({
objects,
request,
transforms,
sortFunction,
}: ApplyExportTransformsOptions): Promise<SavedObject[]> => {
const context = createContext(request);
const byType = splitByType(objects);
let finalObjects: SavedObject[] = [];
for (const [type, typeObjs] of Object.entries(byType)) {
const typeTransformFn = transforms[type];
if (typeTransformFn) {
finalObjects = [
...finalObjects,
...(await applyTransform(typeObjs, typeTransformFn, context)),
];
} else {
finalObjects = [...finalObjects, ...typeObjs];
}
}
if (sortFunction) {
finalObjects.sort(sortFunction);
}
return finalObjects;
};
const applyTransform = async (
objs: SavedObject[],
transformFn: SavedObjectsExportTransform,
context: SavedObjectsExportTransformContext
) => {
const objKeys = objs.map(getObjKey);
let transformedObjects: SavedObject[];
try {
transformedObjects = await transformFn(context, objs);
} catch (e) {
throw SavedObjectsExportError.objectTransformError(objs, e);
}
assertValidTransform(transformedObjects, objKeys);
return transformedObjects;
};
const createContext = (request: KibanaRequest): SavedObjectsExportTransformContext => {
return {
request,
};
};
const splitByType = (objects: SavedObject[]): Record<string, SavedObject[]> => {
return objects.reduce((memo, obj) => {
memo[obj.type] = [...(memo[obj.type] ?? []), obj];
return memo;
}, {} as Record<string, SavedObject[]>);
};
const assertValidTransform = (transformedObjects: SavedObject[], initialKeys: string[]) => {
const transformedKeys = transformedObjects.map(getObjKey);
const missingKeys: string[] = [];
initialKeys.forEach((initialKey) => {
if (!transformedKeys.includes(initialKey)) {
missingKeys.push(initialKey);
}
});
if (missingKeys.length) {
throw SavedObjectsExportError.invalidTransformError(missingKeys);
}
};

View file

@ -36,4 +36,32 @@ export class SavedObjectsExportError extends Error {
objects,
});
}
/**
* Error returned when a {@link SavedObjectsExportTransform | export tranform} threw an error
*/
static objectTransformError(objects: SavedObject[], cause: Error) {
return new SavedObjectsExportError(
'object-transform-error',
'Error transforming objects to export',
{
objects,
cause: cause.message,
}
);
}
/**
* Error returned when a {@link SavedObjectsExportTransform | export tranform} performed an invalid operation
* during the transform, such as removing objects from the export, or changing an object's type or id.
*/
static invalidTransformError(objectKeys: string[]) {
return new SavedObjectsExportError(
'invalid-transform-error',
'Invalid transform performed on objects to export',
{
objectKeys,
}
);
}
}

View file

@ -11,6 +11,8 @@ export {
SavedObjectExportBaseOptions,
SavedObjectsExportByTypeOptions,
SavedObjectsExportResultDetails,
SavedObjectsExportTransformContext,
SavedObjectsExportTransform,
} from './types';
export { ISavedObjectsExporter, SavedObjectsExporter } from './saved_objects_exporter';
export { SavedObjectsExportError } from './errors';

View file

@ -6,24 +6,30 @@
* Public License, v 1.
*/
import type { SavedObject } from '../../../types';
import { SavedObjectsExporter } from './saved_objects_exporter';
import { savedObjectsClientMock } from '../service/saved_objects_client.mock';
import { SavedObjectTypeRegistry } from '../saved_objects_type_registry';
import { httpServerMock } from '../../http/http_server.mocks';
import { Readable } from 'stream';
import { createPromiseFromStreams, createConcatStream } from '@kbn/utils';
async function readStreamToCompletion(stream: Readable) {
async function readStreamToCompletion(stream: Readable): Promise<Array<SavedObject<any>>> {
return createPromiseFromStreams([stream, createConcatStream([])]);
}
const exportSizeLimit = 500;
const request = httpServerMock.createKibanaRequest();
describe('getSortedObjectsForExport()', () => {
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>;
let typeRegistry: SavedObjectTypeRegistry;
let exporter: SavedObjectsExporter;
beforeEach(() => {
typeRegistry = new SavedObjectTypeRegistry();
savedObjectsClient = savedObjectsClientMock.create();
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit });
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, typeRegistry });
});
describe('#exportByTypes', () => {
@ -56,6 +62,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
});
@ -115,6 +122,52 @@ describe('getSortedObjectsForExport()', () => {
`);
});
test('applies the export transforms', async () => {
typeRegistry.registerType({
name: 'foo',
mappings: { properties: {} },
namespaceType: 'single',
hidden: false,
management: {
importableAndExportable: true,
onExport: (ctx, objects) => {
objects.forEach((obj: SavedObject<any>) => {
obj.attributes.foo = 'modified';
});
return objects;
},
},
});
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, typeRegistry });
savedObjectsClient.find.mockResolvedValueOnce({
total: 1,
saved_objects: [
{
id: '1',
type: 'foo',
attributes: {
foo: 'initial',
},
score: 0,
references: [],
},
],
per_page: 1,
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['foo'],
excludeExportDetails: true,
});
const response = await readStreamToCompletion(exportStream);
expect(response).toHaveLength(1);
expect(response[0].attributes.foo).toEqual('modified');
});
test('omits the `namespaces` property from the export', async () => {
savedObjectsClient.find.mockResolvedValueOnce({
total: 2,
@ -146,6 +199,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
});
@ -234,6 +288,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
excludeExportDetails: true,
});
@ -293,6 +348,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
search: 'foo',
});
@ -375,6 +431,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
hasReference: [
{
@ -468,6 +525,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
namespace: 'foo',
});
@ -531,7 +589,7 @@ describe('getSortedObjectsForExport()', () => {
});
test('export selected types throws error when exceeding exportSizeLimit', async () => {
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1 });
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, typeRegistry });
savedObjectsClient.find.mockResolvedValueOnce({
total: 2,
@ -562,6 +620,7 @@ describe('getSortedObjectsForExport()', () => {
});
await expect(
exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't export more than 1 objects"`);
@ -603,6 +662,7 @@ describe('getSortedObjectsForExport()', () => {
],
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern'],
});
const response = await readStreamToCompletion(exportStream);
@ -667,6 +727,7 @@ describe('getSortedObjectsForExport()', () => {
],
});
const exportStream = await exporter.exportByObjects({
request,
objects: [
{
type: 'index-pattern',
@ -759,6 +820,7 @@ describe('getSortedObjectsForExport()', () => {
});
await expect(
exporter.exportByObjects({
request,
objects: [
{
type: 'index-pattern',
@ -774,9 +836,10 @@ describe('getSortedObjectsForExport()', () => {
});
test('export selected objects throws error when exceeding exportSizeLimit', async () => {
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1 });
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, typeRegistry });
const exportOpts = {
request,
objects: [
{
type: 'index-pattern',
@ -803,6 +866,7 @@ describe('getSortedObjectsForExport()', () => {
],
});
const exportStream = await exporter.exportByObjects({
request,
objects: [
{ type: 'multi', id: '1' },
{ type: 'multi', id: '2' },
@ -846,6 +910,7 @@ describe('getSortedObjectsForExport()', () => {
],
});
const exportStream = await exporter.exportByObjects({
request,
objects: [
{
type: 'search',

View file

@ -9,6 +9,7 @@
import { createListStream } from '@kbn/utils';
import { PublicMethodsOf } from '@kbn/utility-types';
import { SavedObject, SavedObjectsClientContract } from '../types';
import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry';
import { fetchNestedDependencies } from './fetch_nested_dependencies';
import { sortObjects } from './sort_objects';
import {
@ -16,8 +17,11 @@ import {
SavedObjectExportBaseOptions,
SavedObjectsExportByObjectOptions,
SavedObjectsExportByTypeOptions,
SavedObjectsExportTransform,
} from './types';
import { SavedObjectsExportError } from './errors';
import { applyExportTransforms } from './apply_export_transforms';
import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils';
/**
* @public
@ -29,17 +33,29 @@ export type ISavedObjectsExporter = PublicMethodsOf<SavedObjectsExporter>;
*/
export class SavedObjectsExporter {
readonly #savedObjectsClient: SavedObjectsClientContract;
readonly #exportTransforms: Record<string, SavedObjectsExportTransform>;
readonly #exportSizeLimit: number;
constructor({
savedObjectsClient,
typeRegistry,
exportSizeLimit,
}: {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
}) {
this.#savedObjectsClient = savedObjectsClient;
this.#exportSizeLimit = exportSizeLimit;
this.#exportTransforms = typeRegistry.getAllTypes().reduce((transforms, type) => {
if (type.management?.onExport) {
return {
...transforms,
[type.name]: type.management.onExport,
};
}
return transforms;
}, {} as Record<string, SavedObjectsExportTransform>);
}
/**
@ -51,7 +67,8 @@ export class SavedObjectsExporter {
*/
public async exportByTypes(options: SavedObjectsExportByTypeOptions) {
const objects = await this.fetchByTypes(options);
return this.processObjects(objects, {
return this.processObjects(objects, byIdAscComparator, {
request: options.request,
includeReferencesDeep: options.includeReferencesDeep,
excludeExportDetails: options.excludeExportDetails,
namespace: options.namespace,
@ -70,7 +87,9 @@ export class SavedObjectsExporter {
throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit);
}
const objects = await this.fetchByObjects(options);
return this.processObjects(objects, {
const comparator = getPreservedOrderComparator(objects);
return this.processObjects(objects, comparator, {
request: options.request,
includeReferencesDeep: options.includeReferencesDeep,
excludeExportDetails: options.excludeExportDetails,
namespace: options.namespace,
@ -79,7 +98,9 @@ export class SavedObjectsExporter {
private async processObjects(
savedObjects: SavedObject[],
sortFunction: SavedObjectComparator,
{
request,
excludeExportDetails = false,
includeReferencesDeep = false,
namespace,
@ -88,6 +109,13 @@ export class SavedObjectsExporter {
let exportedObjects: Array<SavedObject<unknown>>;
let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = [];
savedObjects = await applyExportTransforms({
request,
objects: savedObjects,
transforms: this.#exportTransforms,
sortFunction,
});
if (includeReferencesDeep) {
const fetchResult = await fetchNestedDependencies(
savedObjects,
@ -145,7 +173,7 @@ export class SavedObjectsExporter {
findResponse.saved_objects
// exclude the find-specific `score` property from the exported objects
.map(({ score, ...obj }) => obj)
.sort((a: SavedObject, b: SavedObject) => (a.id > b.id ? 1 : -1))
.sort(byIdAscComparator)
);
}
}

View file

@ -6,10 +6,13 @@
* Public License, v 1.
*/
import { SavedObjectsFindOptionsReference } from '../types';
import { KibanaRequest } from '../../http';
import { SavedObject, SavedObjectsFindOptionsReference } from '../types';
/** @public */
export interface SavedObjectExportBaseOptions {
/** The http request initiating the export. */
request: KibanaRequest;
/** flag to also include all related saved objects in the export stream. */
includeReferencesDeep?: boolean;
/** flag to not append {@link SavedObjectsExportResultDetails | export details} to the end of the export stream. */
@ -64,3 +67,92 @@ export interface SavedObjectsExportResultDetails {
type: string;
}>;
}
/**
* Context passed down to a {@link SavedObjectsExportTransform | export transform function}
*
* @public
*/
export interface SavedObjectsExportTransformContext {
/**
* The request that initiated the export request. Can be used to create scoped
* services or client inside the {@link SavedObjectsExportTransform | transformation}
*/
request: KibanaRequest;
}
/**
* Transformation function used to mutate the exported objects of the associated type.
*
* A type's export transform function will be executed once per user-initiated export,
* for all objects of that type.
*
* @example
* Registering a transform function changing the object's attributes during the export
* ```ts
* // src/plugins/my_plugin/server/plugin.ts
* import { myType } from './saved_objects';
*
* export class Plugin() {
* setup: (core: CoreSetup) => {
* core.savedObjects.registerType({
* ...myType,
* management: {
* ...myType.management,
* onExport: (ctx, objects) => {
* return objects.map((obj) => ({
* ...obj,
* attributes: {
* ...obj.attributes,
* enabled: false,
* }
* })
* }
* },
* });
* }
* }
* ```
*
* @example
* Registering a transform function adding additional objects to the export
* ```ts
* // src/plugins/my_plugin/server/plugin.ts
* import { myType } from './saved_objects';
*
* export class Plugin() {
* setup: (core: CoreSetup) => {
* const savedObjectStartContractPromise = getStartServices().then(
* ([{ savedObjects: savedObjectsStart }]) => savedObjectsStart
* );
*
* core.savedObjects.registerType({
* ...myType,
* management: {
* ...myType.management,
* onExport: async (ctx, objects) => {
* const { getScopedClient } = await savedObjectStartContractPromise;
* const client = getScopedClient(ctx.request);
*
* const depResponse = await client.find({
* type: 'my-nested-object',
* hasReference: objs.map(({ id, type }) => ({ id, type })),
* });
*
* return [...objs, ...depResponse.saved_objects];
* }
* },
* });
* }
* }
* ```
*
* @remarks Trying to change an object's id or type during the transform will result in
* a runtime error during the export process.
*
* @public
*/
export type SavedObjectsExportTransform = <T = unknown>(
context: SavedObjectsExportTransformContext,
objects: Array<SavedObject<T>>
) => SavedObject[] | Promise<SavedObject[]>;

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { byIdAscComparator, getPreservedOrderComparator } from './utils';
import { SavedObject } from '../../../types';
const createObj = (id: string): SavedObject => ({
id,
type: 'dummy',
attributes: {},
references: [],
});
describe('byIdAscComparator', () => {
it('sorts the objects by id asc', () => {
const objs = [createObj('delta'), createObj('alpha'), createObj('beta')];
objs.sort(byIdAscComparator);
expect(objs.map((obj) => obj.id)).toEqual(['alpha', 'beta', 'delta']);
});
});
describe('getPreservedOrderComparator', () => {
it('sorts objects depending on the order of the provided list', () => {
const objA = createObj('A');
const objB = createObj('B');
const objC = createObj('C');
const comparator = getPreservedOrderComparator([objA, objB, objC]);
const objs = [objC, objA, objB];
objs.sort(comparator);
expect(objs.map((obj) => obj.id)).toEqual(['A', 'B', 'C']);
});
it('appends unknown objects at the end of the list and sort them by id', () => {
const objA = createObj('A');
const objB = createObj('B');
const objC = createObj('C');
const addedA = createObj('addedA');
const addedB = createObj('addedB');
const comparator = getPreservedOrderComparator([objA, objB, objC]);
const objs = [addedB, objC, addedA, objA, objB];
objs.sort(comparator);
expect(objs.map((obj) => obj.id)).toEqual(['A', 'B', 'C', 'addedA', 'addedB']);
});
});

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { SavedObject } from '../../../types';
export type SavedObjectComparator = (a: SavedObject, b: SavedObject) => number;
export const getObjKey = (obj: SavedObject) => `${obj.type}|${obj.id}`;
export const byIdAscComparator: SavedObjectComparator = (a: SavedObject, b: SavedObject) =>
a.id > b.id ? 1 : -1;
/**
* Create a comparator that will sort objects depending on their position in the provided array.
* Objects not present in the array will be appended at the end of the list, and sorted by id asc.
*
* @example
* ```ts
* const comparator = getPreservedOrderComparator([objA, objB, objC]);
* const list = [newB, objB, objC, newA, objA]; // with obj.title matching their variable name
* list.sort()
* // list = [objA, objB, objC, newA, newB]
* ```
*/
export const getPreservedOrderComparator = (objects: SavedObject[]): SavedObjectComparator => {
const orderedKeys = objects.map(getObjKey);
return (a: SavedObject, b: SavedObject) => {
const indexA = orderedKeys.indexOf(getObjKey(a));
const indexB = orderedKeys.indexOf(getObjKey(b));
if (indexA > -1 && indexB > -1) {
return indexA - indexB > 0 ? 1 : -1;
}
if (indexA > -1) {
return -1;
}
if (indexB > -1) {
return 1;
}
return byIdAscComparator(a, b);
};
};

View file

@ -38,6 +38,8 @@ export {
SavedObjectsExportByObjectOptions,
SavedObjectsExportResultDetails,
SavedObjectsExportError,
SavedObjectsExportTransformContext,
SavedObjectsExportTransform,
} from './export';
export {

View file

@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema';
import stringify from 'json-stable-stringify';
import { createPromiseFromStreams, createMapStream, createConcatStream } from '@kbn/utils';
import { IRouter } from '../../http';
import { IRouter, KibanaRequest } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
import { SavedObjectConfig } from '../saved_objects_config';
import {
@ -78,7 +78,11 @@ const validateOptions = (
includeReferencesDeep,
search,
}: ExportOptions,
{ exportSizeLimit, supportedTypes }: { exportSizeLimit: number; supportedTypes: string[] }
{
exportSizeLimit,
supportedTypes,
request,
}: { exportSizeLimit: number; supportedTypes: string[]; request: KibanaRequest }
): EitherExportOptions => {
const hasTypes = (types?.length ?? 0) > 0;
const hasObjects = (objects?.length ?? 0) > 0;
@ -106,6 +110,7 @@ const validateOptions = (
objects: objects!,
excludeExportDetails,
includeReferencesDeep,
request,
};
} else {
const validationError = validateTypes(types!, supportedTypes);
@ -118,6 +123,7 @@ const validateOptions = (
search,
excludeExportDetails,
includeReferencesDeep,
request,
};
}
};
@ -165,6 +171,7 @@ export const registerExportRoute = (
let options: EitherExportOptions;
try {
options = validateOptions(cleaned, {
request: req,
exportSizeLimit: maxImportExportSize,
supportedTypes,
});

View file

@ -457,6 +457,7 @@ export class SavedObjectsService
createExporter: (savedObjectsClient) =>
new SavedObjectsExporter({
savedObjectsClient,
typeRegistry: this.typeRegistry,
exportSizeLimit: this.config!.maxImportExportSize,
}),
createImporter: (savedObjectsClient) =>

View file

@ -25,25 +25,68 @@ describe('SavedObjectTypeRegistry', () => {
registry = new SavedObjectTypeRegistry();
});
it('allows to register types', () => {
registry.registerType(createType({ name: 'typeA' }));
registry.registerType(createType({ name: 'typeB' }));
registry.registerType(createType({ name: 'typeC' }));
expect(
registry
.getAllTypes()
.map((type) => type.name)
.sort()
).toEqual(['typeA', 'typeB', 'typeC']);
});
it('throws when trying to register the same type twice', () => {
registry.registerType(createType({ name: 'typeA' }));
registry.registerType(createType({ name: 'typeB' }));
expect(() => {
describe('#registerType', () => {
it('allows to register types', () => {
registry.registerType(createType({ name: 'typeA' }));
}).toThrowErrorMatchingInlineSnapshot(`"Type 'typeA' is already registered"`);
registry.registerType(createType({ name: 'typeB' }));
registry.registerType(createType({ name: 'typeC' }));
expect(
registry
.getAllTypes()
.map((type) => type.name)
.sort()
).toEqual(['typeA', 'typeB', 'typeC']);
});
it('throws when trying to register the same type twice', () => {
registry.registerType(createType({ name: 'typeA' }));
registry.registerType(createType({ name: 'typeB' }));
expect(() => {
registry.registerType(createType({ name: 'typeA' }));
}).toThrowErrorMatchingInlineSnapshot(`"Type 'typeA' is already registered"`);
});
it('throws when `management.onExport` is specified but `management.importableAndExportable` is undefined or false', () => {
expect(() => {
registry.registerType(
createType({
name: 'typeA',
management: {
onExport: (ctx, objs) => objs,
},
})
);
}).toThrowErrorMatchingInlineSnapshot(
`"Type typeA: 'management.importableAndExportable' must be 'true' when specifying 'management.onExport'"`
);
expect(() => {
registry.registerType(
createType({
name: 'typeA',
management: {
importableAndExportable: false,
onExport: (ctx, objs) => objs,
},
})
);
}).toThrowErrorMatchingInlineSnapshot(
`"Type typeA: 'management.importableAndExportable' must be 'true' when specifying 'management.onExport'"`
);
expect(() => {
registry.registerType(
createType({
name: 'typeA',
management: {
importableAndExportable: true,
onExport: (ctx, objs) => objs,
},
})
);
}).not.toThrow();
});
// TODO: same test with 'onImport'
});
describe('#getType', () => {

View file

@ -32,6 +32,7 @@ export class SavedObjectTypeRegistry {
if (this.types.has(type.name)) {
throw new Error(`Type '${type.name}' is already registered`);
}
validateType(type);
this.types.set(type.name, deepFreeze(type));
}
@ -116,3 +117,13 @@ export class SavedObjectTypeRegistry {
return this.types.get(type)?.management?.importableAndExportable ?? false;
}
}
const validateType = ({ name, management }: SavedObjectsType) => {
if (management) {
if (management.onExport && !management.importableAndExportable) {
throw new Error(
`Type ${name}: 'management.importableAndExportable' must be 'true' when specifying 'management.onExport'`
);
}
}
};

View file

@ -9,6 +9,7 @@
import { SavedObjectsClient } from './service/saved_objects_client';
import { SavedObjectsTypeMappingDefinition } from './mappings';
import { SavedObjectMigrationMap } from './migrations';
import { SavedObjectsExportTransform } from './export';
import { SavedObjectsImportHook } from './import/types';
export {
@ -320,6 +321,17 @@ export interface SavedObjectsTypeManagementDefinition {
* {@link Capabilities | uiCapabilities} to check if the user has permission to access the object.
*/
getInAppUrl?: (savedObject: SavedObject<any>) => { path: string; uiCapabilitiesPath: string };
/**
* An optional export transform function that can be used transform the objects of the registered type during
* the export process.
*
* It can be used to either mutate the exported objects, or add additional objects (of any type) to the export list.
*
* See {@link SavedObjectsExportTransform | the transform type documentation} for more info and examples.
*
* @remarks `importableAndExportable` must be `true` to specify this property.
*/
onExport?: SavedObjectsExportTransform;
/**
* An optional {@link SavedObjectsImportHook | import hook} to use when importing given type.
*
@ -359,7 +371,8 @@ export interface SavedObjectsTypeManagementDefinition {
* }
* ```
*
* @remark messages returned in the warnings are user facing and must be translated.
* @remarks messages returned in the warnings are user facing and must be translated.
* @remarks `importableAndExportable` must be `true` to specify this property.
*/
onImport?: SavedObjectsImportHook;
}

View file

@ -2078,6 +2078,7 @@ export interface SavedObjectExportBaseOptions {
excludeExportDetails?: boolean;
includeReferencesDeep?: boolean;
namespace?: string;
request: KibanaRequest;
}
// @public
@ -2402,8 +2403,9 @@ export interface SavedObjectsExportByTypeOptions extends SavedObjectExportBaseOp
export class SavedObjectsExporter {
// (undocumented)
#private;
constructor({ savedObjectsClient, exportSizeLimit, }: {
constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
});
exportByObjects(options: SavedObjectsExportByObjectOptions): Promise<import("stream").Readable>;
@ -2417,8 +2419,10 @@ export class SavedObjectsExportError extends Error {
readonly attributes?: Record<string, any> | undefined;
// (undocumented)
static exportSizeExceeded(limit: number): SavedObjectsExportError;
static invalidTransformError(objectKeys: string[]): SavedObjectsExportError;
// (undocumented)
static objectFetchError(objects: SavedObject[]): SavedObjectsExportError;
static objectTransformError(objects: SavedObject[], cause: Error): SavedObjectsExportError;
// (undocumented)
readonly type: string;
}
@ -2433,6 +2437,14 @@ export interface SavedObjectsExportResultDetails {
}>;
}
// @public
export type SavedObjectsExportTransform = <T = unknown>(context: SavedObjectsExportTransformContext, objects: Array<SavedObject<T>>) => SavedObject[] | Promise<SavedObject[]>;
// @public
export interface SavedObjectsExportTransformContext {
request: KibanaRequest;
}
// @public
export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjectsComplexFieldMapping;
@ -2851,6 +2863,7 @@ export interface SavedObjectsTypeManagementDefinition {
getTitle?: (savedObject: SavedObject<any>) => string;
icon?: string;
importableAndExportable?: boolean;
onExport?: SavedObjectsExportTransform;
onImport?: SavedObjectsImportHook;
}

View file

@ -8,7 +8,34 @@
/** This module is intended for consumption by public to avoid import issues with server-side code */
export { PluginOpaqueId } from './plugins/types';
export * from './saved_objects/types';
export type {
SavedObjectsImportResponse,
SavedObjectsImportSuccess,
SavedObjectsImportConflictError,
SavedObjectsImportAmbiguousConflictError,
SavedObjectsImportUnsupportedTypeError,
SavedObjectsImportMissingReferencesError,
SavedObjectsImportUnknownError,
SavedObjectsImportFailure,
SavedObjectsImportRetry,
SavedObjectsImportWarning,
SavedObjectsImportActionRequiredWarning,
SavedObjectsImportSimpleWarning,
SavedObjectAttributes,
SavedObjectAttribute,
SavedObjectAttributeSingle,
SavedObject,
SavedObjectError,
SavedObjectReference,
SavedObjectsMigrationVersion,
SavedObjectStatusMeta,
SavedObjectsFindOptionsReference,
SavedObjectsFindOptions,
SavedObjectsBaseOptions,
MutatingOperationRefreshSetting,
SavedObjectsClientContract,
SavedObjectsNamespaceType,
} from './saved_objects/types';
export * from './ui_settings/types';
export * from './legacy/types';
export type { EnvironmentMode, PackageInfo } from '@kbn/config';

View file

@ -24,6 +24,7 @@ import * as CSS from 'csstype';
import { Datatable as Datatable_2 } from 'src/plugins/expressions';
import { Datatable as Datatable_3 } from 'src/plugins/expressions/common';
import { DatatableColumn as DatatableColumn_2 } from 'src/plugins/expressions';
import { DetailedPeerCertificate } from 'tls';
import { Ensure } from '@kbn/utility-types';
import { EnvironmentMode } from '@kbn/config';
import { ErrorToastOptions } from 'src/core/public/notifications';
@ -45,6 +46,7 @@ import { History } from 'history';
import { Href } from 'history';
import { HttpSetup } from 'kibana/public';
import { IconType } from '@elastic/eui';
import { IncomingHttpHeaders } from 'http';
import { InjectedIntl } from '@kbn/i18n/react';
import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public';
import { ISearchSource as ISearchSource_2 } from 'src/plugins/data/public';
@ -60,9 +62,11 @@ import { METRIC_TYPE } from '@kbn/analytics';
import { Moment } from 'moment';
import moment from 'moment';
import { NameList } from 'elasticsearch';
import { ObjectType } from '@kbn/config-schema';
import { Observable } from 'rxjs';
import { PackageInfo } from '@kbn/config';
import { Path } from 'history';
import { PeerCertificate } from 'tls';
import { Plugin as Plugin_2 } from 'src/core/public';
import { PluginInitializerContext as PluginInitializerContext_2 } from 'src/core/public';
import { PluginInitializerContext as PluginInitializerContext_3 } from 'kibana/public';
@ -75,6 +79,7 @@ import React from 'react';
import * as React_3 from 'react';
import { RecursiveReadonly } from '@kbn/utility-types';
import { Reporter } from '@kbn/analytics';
import { Request as Request_2 } from '@hapi/hapi';
import { RequestAdapter } from 'src/plugins/inspector/common';
import { RequestStatistics as RequestStatistics_2 } from 'src/plugins/inspector/common';
import { Required } from '@kbn/utility-types';
@ -85,6 +90,7 @@ import { SavedObjectReference } from 'src/core/types';
import { SavedObjectsClientContract } from 'src/core/public';
import { SavedObjectsFindOptions } from 'kibana/public';
import { SavedObjectsFindResponse } from 'kibana/server';
import { SchemaTypeError } from '@kbn/config-schema';
import { Search } from '@elastic/elasticsearch/api/requestParams';
import { SearchResponse } from 'elasticsearch';
import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common';
@ -94,12 +100,14 @@ import { ToastsSetup } from 'kibana/public';
import { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport';
import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport';
import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport';
import { Type } from '@kbn/config-schema';
import { TypeOf } from '@kbn/config-schema';
import { UiActionsSetup } from 'src/plugins/ui_actions/public';
import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { UiCounterMetricType } from '@kbn/analytics';
import { Unit } from '@elastic/datemath';
import { UnregisterCallback } from 'history';
import { URL } from 'url';
import { UserProvidedValues } from 'src/core/server/types';
// Warning: (ae-missing-release-tag) "ACTION_GLOBAL_APPLY_FILTER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)

View file

@ -12,6 +12,7 @@ import { ApplicationStart as ApplicationStart_2 } from 'kibana/public';
import Boom from '@hapi/boom';
import { ConfigDeprecationProvider } from '@kbn/config';
import * as CSS from 'csstype';
import { DetailedPeerCertificate } from 'tls';
import { EmbeddableStart as EmbeddableStart_2 } from 'src/plugins/embeddable/public/plugin';
import { EnvironmentMode } from '@kbn/config';
import { EuiBreadcrumb } from '@elastic/eui';
@ -25,6 +26,7 @@ import { History } from 'history';
import { Href } from 'history';
import { I18nStart as I18nStart_2 } from 'src/core/public';
import { IconType } from '@elastic/eui';
import { IncomingHttpHeaders } from 'http';
import { KibanaClient } from '@elastic/elasticsearch/api/kibana';
import { Location } from 'history';
import { LocationDescriptorObject } from 'history';
@ -32,30 +34,36 @@ import { Logger } from '@kbn/logging';
import { LogMeta } from '@kbn/logging';
import { MaybePromise } from '@kbn/utility-types';
import { NotificationsStart as NotificationsStart_2 } from 'src/core/public';
import { ObjectType } from '@kbn/config-schema';
import { Observable } from 'rxjs';
import { Optional } from '@kbn/utility-types';
import { OverlayRef as OverlayRef_2 } from 'src/core/public';
import { OverlayStart as OverlayStart_2 } from 'src/core/public';
import { PackageInfo } from '@kbn/config';
import { Path } from 'history';
import { PeerCertificate } from 'tls';
import { PluginInitializerContext } from 'src/core/public';
import * as PropTypes from 'prop-types';
import { PublicMethodsOf } from '@kbn/utility-types';
import { PublicUiSettingsParams } from 'src/core/server/types';
import React from 'react';
import { RecursiveReadonly } from '@kbn/utility-types';
import { Request } from '@hapi/hapi';
import * as Rx from 'rxjs';
import { SavedObjectAttributes } from 'kibana/server';
import { SavedObjectAttributes as SavedObjectAttributes_2 } from 'src/core/public';
import { SavedObjectAttributes as SavedObjectAttributes_3 } from 'kibana/public';
import { SchemaTypeError } from '@kbn/config-schema';
import { SimpleSavedObject as SimpleSavedObject_2 } from 'src/core/public';
import { Start as Start_2 } from 'src/plugins/inspector/public';
import { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport';
import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport';
import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport';
import { Type } from '@kbn/config-schema';
import { TypeOf } from '@kbn/config-schema';
import { UiComponent } from 'src/plugins/kibana_utils/public';
import { UnregisterCallback } from 'history';
import { URL } from 'url';
import { UserProvidedValues } from 'src/core/server/types';
// Warning: (ae-missing-release-tag) "ACTION_ADD_PANEL" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)

View file

@ -0,0 +1,149 @@
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-transform:type_1-obj_1",
"source": {
"test-export-transform": {
"title": "test_1-obj_1",
"enabled": true
},
"type": "test-export-transform",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z"
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-transform:type_1-obj_2",
"source": {
"test-export-transform": {
"title": "test_1-obj_2",
"enabled": true
},
"type": "test-export-transform",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z"
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-add:type_2-obj_1",
"source": {
"test-export-add": {
"title": "test_2-obj_1"
},
"type": "test-export-add",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z"
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-add:type_2-obj_2",
"source": {
"test-export-add": {
"title": "test_2-obj_2"
},
"type": "test-export-add",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z"
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-add-dep:type_dep-obj_1",
"source": {
"test-export-add-dep": {
"title": "type_dep-obj_1"
},
"type": "test-export-add-dep",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": [
{
"type": "test-export-add",
"id": "type_2-obj_1"
}
]
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-add-dep:type_dep-obj_2",
"source": {
"test-export-add-dep": {
"title": "type_dep-obj_2"
},
"type": "test-export-add-dep",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": [
{
"type": "test-export-add",
"id": "type_2-obj_2"
}
]
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-invalid-transform:type_3-obj_1",
"source": {
"test-export-invalid-transform": {
"title": "test_2-obj_1"
},
"type": "test-export-invalid-transform",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z"
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-transform-error:type_4-obj_1",
"source": {
"test-export-transform-error": {
"title": "test_2-obj_1"
},
"type": "test-export-transform-error",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z"
}
}
}

View file

@ -0,0 +1,499 @@
{
"type": "index",
"value": {
"index": ".kibana",
"settings": {
"index": {
"number_of_shards": "1",
"auto_expand_replicas": "0-1",
"number_of_replicas": "0"
}
},
"mappings": {
"dynamic": "strict",
"properties": {
"test-export-transform": {
"properties": {
"title": { "type": "text" },
"enabled": { "type": "boolean" }
}
},
"test-export-add": {
"properties": {
"title": { "type": "text" }
}
},
"test-export-add-dep": {
"properties": {
"title": { "type": "text" }
}
},
"test-export-transform-error": {
"properties": {
"title": { "type": "text" }
}
},
"test-export-invalid-transform": {
"properties": {
"title": { "type": "text" }
}
},
"apm-telemetry": {
"properties": {
"has_any_services": {
"type": "boolean"
},
"services_per_agent": {
"properties": {
"go": {
"type": "long",
"null_value": 0
},
"java": {
"type": "long",
"null_value": 0
},
"js-base": {
"type": "long",
"null_value": 0
},
"nodejs": {
"type": "long",
"null_value": 0
},
"python": {
"type": "long",
"null_value": 0
},
"ruby": {
"type": "long",
"null_value": 0
}
}
}
}
},
"canvas-workpad": {
"dynamic": "false",
"properties": {
"@created": {
"type": "date"
},
"@timestamp": {
"type": "date"
},
"id": {
"type": "text",
"index": false
},
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
}
}
},
"config": {
"dynamic": "true",
"properties": {
"accessibility:disableAnimations": {
"type": "boolean"
},
"buildNum": {
"type": "keyword"
},
"dateFormat:tz": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"defaultIndex": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"telemetry:optIn": {
"type": "boolean"
}
}
},
"dashboard": {
"properties": {
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"optionsJSON": {
"type": "text"
},
"panelsJSON": {
"type": "text"
},
"refreshInterval": {
"properties": {
"display": {
"type": "keyword"
},
"pause": {
"type": "boolean"
},
"section": {
"type": "integer"
},
"value": {
"type": "integer"
}
}
},
"timeFrom": {
"type": "keyword"
},
"timeRestore": {
"type": "boolean"
},
"timeTo": {
"type": "keyword"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"map": {
"properties": {
"bounds": {
"type": "geo_shape",
"tree": "quadtree"
},
"description": {
"type": "text"
},
"layerListJSON": {
"type": "text"
},
"mapStateJSON": {
"type": "text"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"graph-workspace": {
"properties": {
"description": {
"type": "text"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"numLinks": {
"type": "integer"
},
"numVertices": {
"type": "integer"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
},
"wsState": {
"type": "text"
}
}
},
"index-pattern": {
"properties": {
"fieldFormatMap": {
"type": "text"
},
"fields": {
"type": "text"
},
"intervalName": {
"type": "keyword"
},
"notExpandable": {
"type": "boolean"
},
"sourceFilters": {
"type": "text"
},
"timeFieldName": {
"type": "keyword"
},
"title": {
"type": "text"
},
"type": {
"type": "keyword"
},
"typeMeta": {
"type": "keyword"
}
}
},
"kql-telemetry": {
"properties": {
"optInCount": {
"type": "long"
},
"optOutCount": {
"type": "long"
}
}
},
"migrationVersion": {
"dynamic": "true",
"properties": {
"index-pattern": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"space": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
},
"namespace": {
"type": "keyword"
},
"search": {
"properties": {
"columns": {
"type": "keyword"
},
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"sort": {
"type": "keyword"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"server": {
"properties": {
"uuid": {
"type": "keyword"
}
}
},
"space": {
"properties": {
"_reserved": {
"type": "boolean"
},
"color": {
"type": "keyword"
},
"description": {
"type": "text"
},
"disabledFeatures": {
"type": "keyword"
},
"initials": {
"type": "keyword"
},
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 2048
}
}
}
}
},
"spaceId": {
"type": "keyword"
},
"telemetry": {
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"timelion-sheet": {
"properties": {
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"timelion_chart_height": {
"type": "integer"
},
"timelion_columns": {
"type": "integer"
},
"timelion_interval": {
"type": "keyword"
},
"timelion_other_interval": {
"type": "keyword"
},
"timelion_rows": {
"type": "integer"
},
"timelion_sheet": {
"type": "text"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"type": {
"type": "keyword"
},
"updated_at": {
"type": "date"
},
"url": {
"properties": {
"accessCount": {
"type": "long"
},
"accessDate": {
"type": "date"
},
"createDate": {
"type": "date"
},
"url": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 2048
}
}
}
}
},
"visualization": {
"properties": {
"description": {
"type": "text"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"savedSearchId": {
"type": "keyword"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
},
"visState": {
"type": "text"
}
}
},
"references": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "keyword"
},
"type": {
"type": "keyword"
}
},
"type": "nested"
}
}
}
}
}

View file

@ -0,0 +1,8 @@
{
"id": "savedObjectExportTransforms",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["saved_object_export_transforms"],
"server": true,
"ui": false
}

View file

@ -0,0 +1,14 @@
{
"name": "saved_object_export_transforms",
"version": "1.0.0",
"main": "target/test/plugin_functional/plugins/saved_object_export_transforms",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "Apache-2.0",
"scripts": {
"kbn": "node ../../../../scripts/kbn.js",
"build": "rm -rf './target' && ../../../../node_modules/.bin/tsc"
}
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { SavedObjectExportTransformsPlugin } from './plugin';
export const plugin = () => new SavedObjectExportTransformsPlugin();

View file

@ -0,0 +1,141 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { Plugin, CoreSetup } from 'kibana/server';
export class SavedObjectExportTransformsPlugin implements Plugin {
public setup({ savedObjects, getStartServices }: CoreSetup, deps: {}) {
const savedObjectStartContractPromise = getStartServices().then(
([{ savedObjects: savedObjectsStart }]) => savedObjectsStart
);
// example of a SO type that will mutates its properties
// during the export transform
savedObjects.registerType({
name: 'test-export-transform',
hidden: false,
namespaceType: 'single',
mappings: {
properties: {
title: { type: 'text' },
enabled: {
type: 'boolean',
},
},
},
management: {
defaultSearchField: 'title',
importableAndExportable: true,
getTitle: (obj) => obj.attributes.title,
onExport: (ctx, objs) => {
return objs.map((obj) => ({
...obj,
attributes: {
...obj.attributes,
enabled: false,
},
}));
},
},
});
// example of a SO type that will add additional objects
// to the export during the export transform
savedObjects.registerType({
name: 'test-export-add',
hidden: false,
namespaceType: 'single',
mappings: {
properties: {
title: { type: 'text' },
},
},
management: {
defaultSearchField: 'title',
importableAndExportable: true,
getTitle: (obj) => obj.attributes.title,
onExport: async (ctx, objs) => {
const { getScopedClient } = await savedObjectStartContractPromise;
const client = getScopedClient(ctx.request);
const objRefs = objs.map(({ id, type }) => ({ id, type }));
const depResponse = await client.find({
type: 'test-export-add-dep',
hasReference: objRefs,
});
return [...objs, ...depResponse.saved_objects];
},
},
});
// dependency of `test_export_transform_2` that will be included
// when exporting them
savedObjects.registerType({
name: 'test-export-add-dep',
hidden: false,
namespaceType: 'single',
mappings: {
properties: {
title: { type: 'text' },
},
},
management: {
defaultSearchField: 'title',
importableAndExportable: true,
getTitle: (obj) => obj.attributes.title,
},
});
/////////////
/////////////
// example of a SO type that will throw an object-transform-error
savedObjects.registerType({
name: 'test-export-transform-error',
hidden: false,
namespaceType: 'single',
mappings: {
properties: {
title: { type: 'text' },
},
},
management: {
defaultSearchField: 'title',
importableAndExportable: true,
getTitle: (obj) => obj.attributes.title,
onExport: (ctx, objs) => {
throw new Error('Error during transform');
},
},
});
// example of a SO type that will throw an invalid-transform-error
savedObjects.registerType({
name: 'test-export-invalid-transform',
hidden: false,
namespaceType: 'single',
mappings: {
properties: {
title: { type: 'text' },
},
},
management: {
defaultSearchField: 'title',
importableAndExportable: true,
getTitle: (obj) => obj.attributes.title,
onExport: (ctx, objs) => {
return objs.map((obj) => ({
...obj,
id: `${obj.id}-mutated`,
}));
},
},
});
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,16 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"index.ts",
"server/**/*.ts",
"../../../../typings/**/*",
],
"exclude": [],
"references": [
{ "path": "../../../../src/core/tsconfig.json" }
]
}

View file

@ -1,8 +1,8 @@
{
"id": "savedObjectHooks",
"id": "savedObjectImportWarnings",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["saved_object_hooks"],
"configPath": ["saved_object_import_warnings"],
"server": true,
"ui": false
}

View file

@ -1,7 +1,7 @@
{
"name": "saved_object_hooks",
"name": "saved_object_import_warnings",
"version": "1.0.0",
"main": "target/test/plugin_functional/plugins/saved_object_hooks",
"main": "target/test/plugin_functional/plugins/saved_object_import_warnings",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"

View file

@ -6,6 +6,6 @@
* Public License, v 1.
*/
import { SavedObjectHooksPlugin } from './plugin';
import { SavedObjectImportWarningsPlugin } from './plugin';
export const plugin = () => new SavedObjectHooksPlugin();
export const plugin = () => new SavedObjectImportWarningsPlugin();

View file

@ -8,7 +8,7 @@
import { Plugin, CoreSetup } from 'kibana/server';
export class SavedObjectHooksPlugin implements Plugin {
export class SavedObjectImportWarningsPlugin implements Plugin {
public setup({ savedObjects }: CoreSetup, deps: {}) {
savedObjects.registerType({
name: 'test_import_warning_1',

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import expect from '@kbn/expect';
import type { SavedObject } from '../../../../src/core/types';
import { PluginFunctionalProviderContext } from '../../services';
function parseNdJson(input: string): Array<SavedObject<any>> {
return input.split('\n').map((str) => JSON.parse(str));
}
export default function ({ getService }: PluginFunctionalProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('export transforms', () => {
before(async () => {
await esArchiver.load(
'../functional/fixtures/es_archiver/saved_objects_management/export_transform'
);
});
after(async () => {
await esArchiver.unload(
'../functional/fixtures/es_archiver/saved_objects_management/export_transform'
);
});
it('allows to mutate the objects during an export', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-transform'],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([
{
id: 'type_1-obj_1',
enabled: false,
},
{
id: 'type_1-obj_2',
enabled: false,
},
]);
});
});
it('allows to add additional objects to an export', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
objects: [
{
type: 'test-export-add',
id: 'type_2-obj_1',
},
],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']);
});
});
it('allows to add additional objects to an export when exporting by type', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-add'],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => obj.id)).to.eql([
'type_2-obj_1',
'type_2-obj_2',
'type_dep-obj_1',
'type_dep-obj_2',
]);
});
});
it('returns a 400 when the type causes a transform error', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-transform-error'],
excludeExportDetails: true,
})
.expect(400)
.then((resp) => {
const { attributes, ...error } = resp.body;
expect(error).to.eql({
error: 'Bad Request',
message: 'Error transforming objects to export',
statusCode: 400,
});
expect(attributes.cause).to.eql('Error during transform');
expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']);
});
});
it('returns a 400 when the type causes an invalid transform', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-invalid-transform'],
excludeExportDetails: true,
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
error: 'Bad Request',
message: 'Invalid transform performed on objects to export',
statusCode: 400,
attributes: {
objectKeys: ['test-export-invalid-transform|type_3-obj_1'],
},
});
});
});
});
}

View file

@ -10,6 +10,7 @@ import { PluginFunctionalProviderContext } from '../../services';
export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
describe('Saved Objects Management', function () {
loadTestFile(require.resolve('./export_transform'));
loadTestFile(require.resolve('./import_warnings'));
});
}

View file

@ -167,6 +167,7 @@ describe('copySavedObjectsToSpaces', () => {
`);
expect(savedObjectsExporter.exportByObjects).toHaveBeenCalledWith({
request: expect.any(Object),
excludeExportDetails: true,
includeReferencesDeep: true,
namespace,

View file

@ -29,6 +29,7 @@ export function copySavedObjectsToSpacesFactory(
options: Pick<CopyOptions, 'includeReferences' | 'objects'>
) => {
const objectStream = await savedObjectsExporter.exportByObjects({
request,
namespace: spaceIdToNamespace(sourceSpaceId),
includeReferencesDeep: options.includeReferences,
excludeExportDetails: true,

View file

@ -174,6 +174,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => {
`);
expect(savedObjectsExporter.exportByObjects).toHaveBeenCalledWith({
request: expect.any(Object),
excludeExportDetails: true,
includeReferencesDeep: true,
namespace,

View file

@ -29,6 +29,7 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory(
options: Pick<CopyOptions, 'includeReferences' | 'objects'>
) => {
const objectStream = await savedObjectsExporter.exportByObjects({
request,
namespace: spaceIdToNamespace(sourceSpaceId),
includeReferencesDeep: options.includeReferences,
excludeExportDetails: true,