add isExportable SO export API (#101860)

* add `isExportable` SO export API

* add warning when export contains excluded objects

* add FTR test

* fix API integration assertions

* lint

* fix assertions again

* doc

* update generated doc

* fix esarchiver paths

* use maps instead of objects

* SavedObjectsExportablePredicate is no longer async

* more docs

* generated doc

* use info instead of warning when export contains excluded objects

* try/catch on isExportable call and add exclusion reason

* add FTR test for errored objects

* log error if isExportable throws
This commit is contained in:
Pierre Gayvallet 2021-06-21 10:06:54 +02:00 committed by GitHub
parent 693823f8c5
commit 59d7f33115
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1968 additions and 345 deletions

View file

@ -165,6 +165,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsDeleteOptions](./kibana-plugin-core-server.savedobjectsdeleteoptions.md) | |
| [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) |
| [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.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) | |

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; [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md) &gt; [id](./kibana-plugin-core-server.savedobjectsexportexcludedobject.id.md)
## SavedObjectsExportExcludedObject.id property
id of the excluded object
<b>Signature:</b>
```typescript
id: string;
```

View file

@ -0,0 +1,21 @@
<!-- 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; [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md)
## SavedObjectsExportExcludedObject interface
<b>Signature:</b>
```typescript
export interface SavedObjectsExportExcludedObject
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [id](./kibana-plugin-core-server.savedobjectsexportexcludedobject.id.md) | <code>string</code> | id of the excluded object |
| [reason](./kibana-plugin-core-server.savedobjectsexportexcludedobject.reason.md) | <code>string</code> | optional cause of the exclusion |
| [type](./kibana-plugin-core-server.savedobjectsexportexcludedobject.type.md) | <code>string</code> | type of the excluded object |

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; [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md) &gt; [reason](./kibana-plugin-core-server.savedobjectsexportexcludedobject.reason.md)
## SavedObjectsExportExcludedObject.reason property
optional cause of the exclusion
<b>Signature:</b>
```typescript
reason?: string;
```

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; [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md) &gt; [type](./kibana-plugin-core-server.savedobjectsexportexcludedobject.type.md)
## SavedObjectsExportExcludedObject.type property
type of the excluded object
<b>Signature:</b>
```typescript
type: string;
```

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; [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) &gt; [excludedObjects](./kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjects.md)
## SavedObjectsExportResultDetails.excludedObjects property
excluded objects details
<b>Signature:</b>
```typescript
excludedObjects: SavedObjectsExportExcludedObject[];
```

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; [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) &gt; [excludedObjectsCount](./kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjectscount.md)
## SavedObjectsExportResultDetails.excludedObjectsCount property
number of objects that were excluded from the export
<b>Signature:</b>
```typescript
excludedObjectsCount: number;
```

View file

@ -16,6 +16,8 @@ export interface SavedObjectsExportResultDetails
| Property | Type | Description |
| --- | --- | --- |
| [excludedObjects](./kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjects.md) | <code>SavedObjectsExportExcludedObject[]</code> | excluded objects details |
| [excludedObjectsCount](./kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjectscount.md) | <code>number</code> | number of objects that were excluded from the export |
| [exportedCount](./kibana-plugin-core-server.savedobjectsexportresultdetails.exportedcount.md) | <code>number</code> | number of successfully exported objects |
| [missingRefCount](./kibana-plugin-core-server.savedobjectsexportresultdetails.missingrefcount.md) | <code>number</code> | number of missing references |
| [missingReferences](./kibana-plugin-core-server.savedobjectsexportresultdetails.missingreferences.md) | <code>Array&lt;{</code><br/><code> id: string;</code><br/><code> type: string;</code><br/><code> }&gt;</code> | missing references details |

View file

@ -11,7 +11,7 @@ A type's export transform function will be executed once per user-initiated expo
<b>Signature:</b>
```typescript
export declare type SavedObjectsExportTransform = <T = unknown>(context: SavedObjectsExportTransformContext, objects: Array<SavedObject<T>>) => SavedObject[] | Promise<SavedObject[]>;
export declare type SavedObjectsExportTransform<T = unknown> = (context: SavedObjectsExportTransformContext, objects: Array<SavedObject<T>>) => SavedObject[] | Promise<SavedObject[]>;
```
## Remarks

View file

@ -52,6 +52,6 @@ export class Plugin() {
| Property | Type | Description |
| --- | --- | --- |
| [addClientWrapper](./kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md) | <code>(priority: number, id: string, factory: SavedObjectsClientWrapperFactory) =&gt; void</code> | Add a [client wrapper factory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) with the given priority. |
| [registerType](./kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | <code>(type: SavedObjectsType) =&gt; void</code> | Register a [savedObjects type](./kibana-plugin-core-server.savedobjectstype.md) definition.<!-- -->See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) and [migration format](./kibana-plugin-core-server.savedobjectmigrationmap.md) for more details about these. |
| [registerType](./kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | <code>&lt;Attributes = any&gt;(type: SavedObjectsType&lt;Attributes&gt;) =&gt; void</code> | Register a [savedObjects type](./kibana-plugin-core-server.savedobjectstype.md) definition.<!-- -->See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) and [migration format](./kibana-plugin-core-server.savedobjectmigrationmap.md) for more details about these. |
| [setClientFactoryProvider](./kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | <code>(clientFactoryProvider: SavedObjectsClientFactoryProvider) =&gt; void</code> | Set the default [factory provider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) for creating Saved Objects clients. Only one provider can be set, subsequent calls to this method will fail. |

View file

@ -11,7 +11,7 @@ See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdef
<b>Signature:</b>
```typescript
registerType: (type: SavedObjectsType) => void;
registerType: <Attributes = any>(type: SavedObjectsType<Attributes>) => void;
```
## Example

View file

@ -9,5 +9,5 @@ An optional [saved objects management section](./kibana-plugin-core-server.saved
<b>Signature:</b>
```typescript
management?: SavedObjectsTypeManagementDefinition;
management?: SavedObjectsTypeManagementDefinition<Attributes>;
```

View file

@ -7,7 +7,7 @@
<b>Signature:</b>
```typescript
export interface SavedObjectsType
export interface SavedObjectsType<Attributes = any>
```
## Remarks
@ -54,7 +54,7 @@ Example after converting to a multi-namespace (shareable) type in 8.1:
Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. |
| [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | <code>boolean</code> | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an <code>extraType</code> when creating the repository.<!-- -->See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md)<!-- -->. |
| [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | <code>string</code> | If defined, the type instances will be stored in the given index instead of the default one. |
| [management](./kibana-plugin-core-server.savedobjectstype.management.md) | <code>SavedObjectsTypeManagementDefinition</code> | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. |
| [management](./kibana-plugin-core-server.savedobjectstype.management.md) | <code>SavedObjectsTypeManagementDefinition&lt;Attributes&gt;</code> | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. |
| [mappings](./kibana-plugin-core-server.savedobjectstype.mappings.md) | <code>SavedObjectsTypeMappingDefinition</code> | The [mapping definition](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) for the type. |
| [migrations](./kibana-plugin-core-server.savedobjectstype.migrations.md) | <code>SavedObjectMigrationMap &#124; (() =&gt; SavedObjectMigrationMap)</code> | An optional map of [migrations](./kibana-plugin-core-server.savedobjectmigrationfn.md) or a function returning a map of [migrations](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used to migrate the type. |
| [name](./kibana-plugin-core-server.savedobjectstype.name.md) | <code>string</code> | The name of the type, which is also used as the internal id. |

View file

@ -9,5 +9,5 @@ Function returning the url to use to redirect to the editing page of this object
<b>Signature:</b>
```typescript
getEditUrl?: (savedObject: SavedObject<any>) => string;
getEditUrl?: (savedObject: SavedObject<Attributes>) => string;
```

View file

@ -9,7 +9,7 @@ Function returning the url to use to redirect to this object from the management
<b>Signature:</b>
```typescript
getInAppUrl?: (savedObject: SavedObject<any>) => {
getInAppUrl?: (savedObject: SavedObject<Attributes>) => {
path: string;
uiCapabilitiesPath: string;
};

View file

@ -9,5 +9,5 @@ Function returning the title to display in the management table. If not defined,
<b>Signature:</b>
```typescript
getTitle?: (savedObject: SavedObject<any>) => string;
getTitle?: (savedObject: SavedObject<Attributes>) => string;
```

View file

@ -0,0 +1,49 @@
<!-- 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; [isExportable](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.isexportable.md)
## SavedObjectsTypeManagementDefinition.isExportable property
Optional hook to specify whether an object should be exportable.
If specified, `isExportable` will be called during export for each of this type's objects in the export, and the ones not matching the predicate will be excluded from the export.
When implementing both `isExportable` and `onExport`<!-- -->, it is mandatory that `isExportable` returns the same value for an object before and after going though the export transform. E.g `isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)`
<b>Signature:</b>
```typescript
isExportable?: SavedObjectsExportablePredicate<Attributes>;
```
## Remarks
`importableAndExportable` must be `true` to specify this property.
## Example
Registering a type with a per-object exportability predicate
```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,
isExportable: (object) => {
if (object.attributes.myCustomAttr === 'foo') {
return false;
}
return true;
}
},
});
}
}
```

View file

@ -9,7 +9,7 @@ Configuration options for the [type](./kibana-plugin-core-server.savedobjectstyp
<b>Signature:</b>
```typescript
export interface SavedObjectsTypeManagementDefinition
export interface SavedObjectsTypeManagementDefinition<Attributes = any>
```
## Properties
@ -17,11 +17,12 @@ export interface SavedObjectsTypeManagementDefinition
| Property | Type | Description |
| --- | --- | --- |
| [defaultSearchField](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.defaultsearchfield.md) | <code>string</code> | The default search field to use for this type. Defaults to <code>id</code>. |
| [getEditUrl](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.getediturl.md) | <code>(savedObject: SavedObject&lt;any&gt;) =&gt; string</code> | Function returning the url to use to redirect to the editing page of this object. If not defined, editing will not be allowed. |
| [getInAppUrl](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.getinappurl.md) | <code>(savedObject: SavedObject&lt;any&gt;) =&gt; {</code><br/><code> path: string;</code><br/><code> uiCapabilitiesPath: string;</code><br/><code> }</code> | Function returning the url to use to redirect to this object from the management section. If not defined, redirecting to the object will not be allowed. |
| [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. |
| [getEditUrl](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.getediturl.md) | <code>(savedObject: SavedObject&lt;Attributes&gt;) =&gt; string</code> | Function returning the url to use to redirect to the editing page of this object. If not defined, editing will not be allowed. |
| [getInAppUrl](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.getinappurl.md) | <code>(savedObject: SavedObject&lt;Attributes&gt;) =&gt; {</code><br/><code> path: string;</code><br/><code> uiCapabilitiesPath: string;</code><br/><code> }</code> | Function returning the url to use to redirect to this object from the management section. If not defined, redirecting to the object will not be allowed. |
| [getTitle](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.gettitle.md) | <code>(savedObject: SavedObject&lt;Attributes&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. |
| [isExportable](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.isexportable.md) | <code>SavedObjectsExportablePredicate&lt;Attributes&gt;</code> | Optional hook to specify whether an object should be exportable.<!-- -->If specified, <code>isExportable</code> will be called during export for each of this type's objects in the export, and the ones not matching the predicate will be excluded from the export.<!-- -->When implementing both <code>isExportable</code> and <code>onExport</code>, it is mandatory that <code>isExportable</code> returns the same value for an object before and after going though the export transform. E.g <code>isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)</code> |
| [onExport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md) | <code>SavedObjectsExportTransform&lt;Attributes&gt;</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.<!-- -->When implementing both <code>isExportable</code> and <code>onExport</code>, it is mandatory that <code>isExportable</code> returns the same value for an object before and after going though the export transform. E.g <code>isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)</code> |
| [onImport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onimport.md) | <code>SavedObjectsImportHook&lt;Attributes&gt;</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

@ -10,10 +10,12 @@ It can be used to either mutate the exported objects, or add additional objects
See [the transform type documentation](./kibana-plugin-core-server.savedobjectsexporttransform.md) for more info and examples.
When implementing both `isExportable` and `onExport`<!-- -->, it is mandatory that `isExportable` returns the same value for an object before and after going though the export transform. E.g `isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)`
<b>Signature:</b>
```typescript
onExport?: SavedObjectsExportTransform;
onExport?: SavedObjectsExportTransform<Attributes>;
```
## Remarks

View file

@ -11,7 +11,7 @@ Import hooks are executed during the savedObjects import process and allow to in
<b>Signature:</b>
```typescript
onImport?: SavedObjectsImportHook;
onImport?: SavedObjectsImportHook<Attributes>;
```
## Remarks

View file

@ -11,9 +11,9 @@ To only get the visible types (which is the most common use case), use `getVisib
<b>Signature:</b>
```typescript
getAllTypes(): SavedObjectsType[];
getAllTypes(): SavedObjectsType<any>[];
```
<b>Returns:</b>
`SavedObjectsType[]`
`SavedObjectsType<any>[]`

View file

@ -9,9 +9,9 @@ Return all [types](./kibana-plugin-core-server.savedobjectstype.md) currently re
<b>Signature:</b>
```typescript
getImportableAndExportableTypes(): SavedObjectsType[];
getImportableAndExportableTypes(): SavedObjectsType<any>[];
```
<b>Returns:</b>
`SavedObjectsType[]`
`SavedObjectsType<any>[]`

View file

@ -9,7 +9,7 @@ Return the [type](./kibana-plugin-core-server.savedobjectstype.md) definition fo
<b>Signature:</b>
```typescript
getType(type: string): SavedObjectsType | undefined;
getType(type: string): SavedObjectsType<any> | undefined;
```
## Parameters
@ -20,5 +20,5 @@ getType(type: string): SavedObjectsType | undefined;
<b>Returns:</b>
`SavedObjectsType | undefined`
`SavedObjectsType<any> | undefined`

View file

@ -11,9 +11,9 @@ A visible type is a type that doesn't explicitly define `hidden=true` during reg
<b>Signature:</b>
```typescript
getVisibleTypes(): SavedObjectsType[];
getVisibleTypes(): SavedObjectsType<any>[];
```
<b>Returns:</b>
`SavedObjectsType[]`
`SavedObjectsType<any>[]`

View file

@ -295,6 +295,7 @@ export type {
SavedObjectsCreatePointInTimeFinderOptions,
SavedObjectsCreateOptions,
SavedObjectsExportResultDetails,
SavedObjectsExportExcludedObject,
SavedObjectsFindResult,
SavedObjectsFindResponse,
SavedObjectsImportConflictError,

View file

@ -27,6 +27,8 @@ const createTransform = (
implementation: SavedObjectsExportTransform = (ctx, objs) => objs
): jest.MockedFunction<SavedObjectsExportTransform> => jest.fn(implementation);
const toMap = <V>(record: Record<string, V>): Map<string, V> => new Map(Object.entries(record));
const expectedContext = {
request: expect.any(KibanaRequest),
};
@ -49,10 +51,10 @@ describe('applyExportTransforms', () => {
await applyExportTransforms({
request,
objects: [foo1, bar1, foo2],
transforms: {
transforms: toMap({
foo: fooTransform,
bar: barTransform,
},
}),
});
expect(fooTransform).toHaveBeenCalledTimes(1);
@ -71,10 +73,10 @@ describe('applyExportTransforms', () => {
await applyExportTransforms({
request,
objects: [foo1],
transforms: {
transforms: toMap({
foo: fooTransform,
bar: barTransform,
},
}),
});
expect(fooTransform).toHaveBeenCalledTimes(1);
@ -100,10 +102,10 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, bar1, foo2],
transforms: {
transforms: toMap({
foo: fooTransform,
bar: barTransform,
},
}),
});
expect(result).toEqual([foo1, foo2, dolly1, bar1, hello1]);
@ -123,9 +125,9 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, foo2, bar1, bar2],
transforms: {
transforms: toMap({
foo: fooTransform,
},
}),
});
expect(result).toEqual([foo1, foo2, dolly1, bar1, bar2]);
@ -150,9 +152,9 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, foo2],
transforms: {
transforms: toMap({
foo: fooTransform,
},
}),
});
expect(result).toEqual([foo1, foo2].map(disableFoo));
@ -175,10 +177,10 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, bar1],
transforms: {
transforms: toMap({
foo: fooTransform,
bar: barTransform,
},
}),
});
expect(result).toEqual([foo1, dolly1, bar1, hello1]);
@ -201,10 +203,10 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, bar1],
transforms: {
transforms: toMap({
foo: fooTransform,
bar: barTransform,
},
}),
sortFunction: (obj1, obj2) => (obj1.id > obj2.id ? 1 : -1),
});
@ -223,9 +225,9 @@ describe('applyExportTransforms', () => {
applyExportTransforms({
request,
objects: [foo1, foo2],
transforms: {
transforms: toMap({
foo: fooTransform,
},
}),
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
@ -247,9 +249,9 @@ describe('applyExportTransforms', () => {
applyExportTransforms({
request,
objects: [foo1, foo2],
transforms: {
transforms: toMap({
foo: fooTransform,
},
}),
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
@ -271,9 +273,9 @@ describe('applyExportTransforms', () => {
applyExportTransforms({
request,
objects: [foo1, foo2],
transforms: {
transforms: toMap({
foo: fooTransform,
},
}),
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
@ -291,9 +293,9 @@ describe('applyExportTransforms', () => {
applyExportTransforms({
request,
objects: [foo1],
transforms: {
transforms: toMap({
foo: fooTransform,
},
}),
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Error transforming objects to export"`);
});

View file

@ -15,7 +15,7 @@ import { getObjKey, SavedObjectComparator } from './utils';
interface ApplyExportTransformsOptions {
objects: SavedObject[];
request: KibanaRequest;
transforms: Record<string, SavedObjectsExportTransform>;
transforms: Map<string, SavedObjectsExportTransform>;
sortFunction?: SavedObjectComparator;
}
@ -30,7 +30,7 @@ export const applyExportTransforms = async ({
let finalObjects: SavedObject[] = [];
for (const [type, typeObjs] of Object.entries(byType)) {
const typeTransformFn = transforms[type];
const typeTransformFn = transforms.get(type);
if (typeTransformFn) {
finalObjects = [
...finalObjects,

View file

@ -9,9 +9,12 @@
import { applyExportTransformsMock } from './collect_exported_objects.test.mocks';
import { savedObjectsClientMock } from '../../mocks';
import { httpServerMock } from '../../http/http_server.mocks';
import { loggerMock } from '../../logging/logger.mock';
import { SavedObject, SavedObjectError } from '../../../types';
import { SavedObjectTypeRegistry } from '../saved_objects_type_registry';
import type { SavedObjectsExportTransform } from './types';
import { collectExportedObjects } from './collect_exported_objects';
import { collectExportedObjects, ExclusionReason } from './collect_exported_objects';
import { SavedObjectsExportablePredicate } from '../types';
const createObject = (parts: Partial<SavedObject>): SavedObject => ({
id: 'id',
@ -29,14 +32,48 @@ const createError = (parts: Partial<SavedObjectError> = {}): SavedObjectError =>
});
const toIdTuple = (obj: SavedObject) => ({ type: obj.type, id: obj.id });
const toExcludedObject = (obj: SavedObject, reason: ExclusionReason = 'excluded') => ({
type: obj.type,
id: obj.id,
reason,
});
const toMap = <V>(record: Record<string, V>): Map<string, V> => new Map(Object.entries(record));
describe('collectExportedObjects', () => {
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>;
let request: ReturnType<typeof httpServerMock.createKibanaRequest>;
let logger: ReturnType<typeof loggerMock.create>;
let typeRegistry: SavedObjectTypeRegistry;
const registerType = (
name: string,
{
onExport,
isExportable,
}: {
onExport?: SavedObjectsExportTransform;
isExportable?: SavedObjectsExportablePredicate;
} = {}
) => {
typeRegistry.registerType({
name,
hidden: false,
namespaceType: 'single',
mappings: { properties: {} },
management: {
importableAndExportable: true,
onExport,
isExportable,
},
});
};
beforeEach(() => {
typeRegistry = new SavedObjectTypeRegistry();
savedObjectsClient = savedObjectsClientMock.create();
request = httpServerMock.createKibanaRequest();
logger = loggerMock.create();
applyExportTransformsMock.mockImplementation(({ objects }) => objects);
savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [] });
});
@ -58,23 +95,62 @@ describe('collectExportedObjects', () => {
});
const fooTransform: SavedObjectsExportTransform = jest.fn();
registerType('foo', { onExport: fooTransform });
await collectExportedObjects({
objects: [obj1, obj2],
savedObjectsClient,
request,
exportTransforms: { foo: fooTransform },
typeRegistry,
includeReferences: true,
logger,
});
expect(applyExportTransformsMock).toHaveBeenCalledTimes(1);
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [obj1, obj2],
transforms: { foo: fooTransform },
transforms: toMap({ foo: fooTransform }),
request,
});
});
it('calls `isExportable` with the correct parameters', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
});
const foo2 = createObject({
type: 'foo',
id: '2',
});
const bar3 = createObject({
type: 'bar',
id: '3',
});
const fooExportable: SavedObjectsExportablePredicate = jest.fn().mockReturnValue(true);
registerType('foo', { isExportable: fooExportable });
const barExportable: SavedObjectsExportablePredicate = jest.fn().mockReturnValue(true);
registerType('bar', { isExportable: barExportable });
await collectExportedObjects({
objects: [foo1, foo2, bar3],
savedObjectsClient,
request,
typeRegistry,
includeReferences: true,
logger,
});
expect(fooExportable).toHaveBeenCalledTimes(2);
expect(fooExportable).toHaveBeenCalledWith(foo1);
expect(fooExportable).toHaveBeenCalledWith(foo2);
expect(barExportable).toHaveBeenCalledTimes(1);
expect(barExportable).toHaveBeenCalledWith(bar3);
});
it('returns the collected objects', async () => {
const foo1 = createObject({
type: 'foo',
@ -96,6 +172,10 @@ describe('collectExportedObjects', () => {
id: '3',
});
registerType('foo');
registerType('bar');
registerType('dolly');
applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]);
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [bar2],
@ -105,14 +185,220 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: true,
logger,
});
expect(missingRefs).toHaveLength(0);
expect(objects.map(toIdTuple)).toEqual([foo1, dolly3, bar2].map(toIdTuple));
});
it('excludes objects filtered by the `isExportable` predicate', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
});
const foo2 = createObject({
type: 'foo',
id: '2',
});
const bar3 = createObject({
type: 'bar',
id: '3',
});
registerType('foo', { isExportable: (obj) => obj.id !== '2' });
registerType('bar', { isExportable: () => true });
const { objects, excludedObjects } = await collectExportedObjects({
objects: [foo1, foo2, bar3],
savedObjectsClient,
request,
typeRegistry,
includeReferences: true,
logger,
});
expect(objects).toEqual([foo1, bar3]);
expect(excludedObjects).toEqual([foo2].map((obj) => toExcludedObject(obj)));
});
it('excludes objects when the predicate throws', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
});
const foo2 = createObject({
type: 'foo',
id: '2',
});
const bar3 = createObject({
type: 'bar',
id: '3',
});
registerType('foo', {
isExportable: (obj) => {
if (obj.id === '1') {
throw new Error('reason');
}
return true;
},
});
registerType('bar', { isExportable: () => true });
const { objects, excludedObjects } = await collectExportedObjects({
objects: [foo1, foo2, bar3],
savedObjectsClient,
request,
typeRegistry,
includeReferences: true,
logger,
});
expect(objects).toEqual([foo2, bar3]);
expect(excludedObjects).toEqual(
[foo1].map((obj) => toExcludedObject(obj, 'predicate_error'))
);
});
it('logs an error for each predicate error', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
});
const foo2 = createObject({
type: 'foo',
id: '2',
});
const foo3 = createObject({
type: 'foo',
id: '3',
});
registerType('foo', {
isExportable: (obj) => {
if (obj.id !== '2') {
throw new Error('reason');
}
return true;
},
});
const { objects, excludedObjects } = await collectExportedObjects({
objects: [foo1, foo2, foo3],
savedObjectsClient,
request,
typeRegistry,
includeReferences: true,
logger,
});
expect(objects).toEqual([foo2]);
expect(excludedObjects).toEqual(
[foo1, foo3].map((obj) => toExcludedObject(obj, 'predicate_error'))
);
expect(logger.error).toHaveBeenCalledTimes(2);
const logMessages = logger.error.mock.calls.map((call) => call[0]);
expect(
(logMessages[0] as string).startsWith(
`Error invoking "isExportable" for object foo:1. Error was: Error: reason`
)
).toBe(true);
expect(
(logMessages[1] as string).startsWith(
`Error invoking "isExportable" for object foo:3. Error was: Error: reason`
)
).toBe(true);
});
it('excludes references filtered by the `isExportable` predicate', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
references: [
{
type: 'bar',
id: '2',
name: 'bar-2',
},
{
type: 'excluded',
id: '1',
name: 'excluded-1',
},
],
});
const bar2 = createObject({
type: 'bar',
id: '2',
});
const excluded1 = createObject({
type: 'excluded',
id: '1',
});
registerType('foo');
registerType('bar');
registerType('excluded', { isExportable: () => false });
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [bar2, excluded1],
});
const { objects, excludedObjects } = await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
typeRegistry,
includeReferences: true,
logger,
});
expect(objects).toEqual([foo1, bar2]);
expect(excludedObjects).toEqual([excluded1].map((obj) => toExcludedObject(obj)));
});
it('excludes additional objects filtered by the `isExportable` predicate', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
});
const bar2 = createObject({
type: 'bar',
id: '2',
});
const excluded1 = createObject({
type: 'excluded',
id: '1',
});
registerType('foo');
registerType('bar');
registerType('excluded', { isExportable: () => false });
applyExportTransformsMock.mockImplementationOnce(({ objects }) => [
...objects,
bar2,
excluded1,
]);
const { objects, excludedObjects } = await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
typeRegistry,
includeReferences: true,
logger,
});
expect(objects).toEqual([foo1, bar2]);
expect(excludedObjects).toEqual([excluded1].map((obj) => toExcludedObject(obj)));
});
it('returns the missing references', async () => {
const foo1 = createObject({
type: 'foo',
@ -163,8 +449,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: true,
logger,
});
expect(missingRefs).toEqual([missing1, missing2].map(toIdTuple));
@ -185,8 +472,9 @@ describe('collectExportedObjects', () => {
objects: [obj1, obj2],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: true,
logger,
});
expect(missingRefs).toHaveLength(0);
@ -228,8 +516,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: true,
logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
@ -241,12 +530,12 @@ describe('collectExportedObjects', () => {
expect(applyExportTransformsMock).toHaveBeenCalledTimes(2);
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [foo1],
transforms: {},
transforms: toMap({}),
request,
});
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [bar2],
transforms: {},
transforms: toMap({}),
request,
});
});
@ -302,8 +591,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: true,
logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2);
@ -366,8 +656,9 @@ describe('collectExportedObjects', () => {
objects: [foo1, bar2],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: true,
logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
@ -411,8 +702,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: true,
logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
@ -474,8 +766,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: true,
logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2);
@ -490,6 +783,67 @@ describe('collectExportedObjects', () => {
expect.any(Object)
);
});
it('excludes references filtered by the `isExportable` predicate for additional objects returned by the export transform', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
});
const bar2 = createObject({
type: 'bar',
id: '2',
references: [
{
type: 'dolly',
id: '3',
name: 'dolly-3',
},
{
type: 'baz',
id: '4',
name: 'baz-4',
},
],
});
const dolly3 = createObject({
type: 'dolly',
id: '3',
references: [
{
type: 'baz',
id: '4',
name: 'baz-4',
},
],
});
const baz4 = createObject({
type: 'baz',
id: '4',
});
registerType('foo');
registerType('bar');
registerType('dolly');
registerType('baz', { isExportable: () => false });
applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, bar2]);
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [dolly3, baz4],
});
const { objects, excludedObjects } = await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
typeRegistry,
includeReferences: true,
logger,
});
expect(objects).toEqual([foo1, bar2, dolly3]);
expect(excludedObjects).toEqual([baz4].map((obj) => toExcludedObject(obj)));
});
});
describe('when `includeReferences` is `false`', () => {
@ -510,8 +864,9 @@ describe('collectExportedObjects', () => {
objects: [obj1],
savedObjectsClient,
request,
exportTransforms: {},
typeRegistry,
includeReferences: false,
logger,
});
expect(missingRefs).toHaveLength(0);

View file

@ -8,7 +8,9 @@
import type { SavedObject } from '../../../types';
import type { KibanaRequest } from '../../http';
import { SavedObjectsClientContract } from '../types';
import type { Logger } from '../../logging';
import { SavedObjectsClientContract, SavedObjectsExportablePredicate } from '../types';
import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry';
import type { SavedObjectsExportTransform } from './types';
import { applyExportTransforms } from './apply_export_transforms';
@ -22,41 +24,80 @@ interface CollectExportedObjectOptions {
/** The http request initiating the export. */
request: KibanaRequest;
/** export transform per type */
exportTransforms: Record<string, SavedObjectsExportTransform>;
typeRegistry: ISavedObjectTypeRegistry;
/** logger to use to log potential errors */
logger: Logger;
}
interface CollectExportedObjectResult {
objects: SavedObject[];
excludedObjects: ExcludedObject[];
missingRefs: CollectedReference[];
}
interface ExcludedObject {
id: string;
type: string;
reason: ExclusionReason;
}
export type ExclusionReason = 'predicate_error' | 'excluded';
export const collectExportedObjects = async ({
objects,
includeReferences = true,
namespace,
request,
exportTransforms,
typeRegistry,
savedObjectsClient,
logger,
}: CollectExportedObjectOptions): Promise<CollectExportedObjectResult> => {
const exportTransforms = buildTransforms(typeRegistry);
const isExportable = buildIsExportable(typeRegistry);
const collectedObjects: SavedObject[] = [];
const collectedMissingRefs: CollectedReference[] = [];
const collectedNonExportableObjects: ExcludedObject[] = [];
const alreadyProcessed: Set<string> = new Set();
let currentObjects = objects;
do {
const transformed = (
currentObjects = currentObjects.filter((object) => !alreadyProcessed.has(objKey(object)));
// first, evict current objects that are not exportable
const {
exportable: untransformedExportableInitialObjects,
nonExportable: nonExportableInitialObjects,
} = await splitByExportability(currentObjects, isExportable, logger);
collectedNonExportableObjects.push(...nonExportableInitialObjects);
nonExportableInitialObjects.forEach((obj) => alreadyProcessed.add(objKey(obj)));
// second, apply export transforms to exportable objects
const transformedObjects = (
await applyExportTransforms({
request,
objects: currentObjects,
objects: untransformedExportableInitialObjects,
transforms: exportTransforms,
})
).filter((object) => !alreadyProcessed.has(objKey(object)));
transformedObjects.forEach((obj) => alreadyProcessed.add(objKey(obj)));
transformed.forEach((obj) => alreadyProcessed.add(objKey(obj)));
collectedObjects.push(...transformed);
// last, evict additional objects that are not exportable
const { included: exportableInitialObjects, excluded: additionalObjects } = splitByKeys(
transformedObjects,
untransformedExportableInitialObjects.map((obj) => objKey(obj))
);
const {
exportable: exportableAdditionalObjects,
nonExportable: nonExportableAdditionalObjects,
} = await splitByExportability(additionalObjects, isExportable, logger);
const allExportableObjects = [...exportableInitialObjects, ...exportableAdditionalObjects];
collectedNonExportableObjects.push(...nonExportableAdditionalObjects);
collectedObjects.push(...allExportableObjects);
// if `includeReferences` is true, recurse on exportable objects' references.
if (includeReferences) {
const references = collectReferences(transformed, alreadyProcessed);
const references = collectReferences(allExportableObjects, alreadyProcessed);
if (references.length) {
const { objects: fetchedObjects, missingRefs } = await fetchReferences({
references,
@ -75,6 +116,7 @@ export const collectExportedObjects = async ({
return {
objects: collectedObjects,
excludedObjects: collectedNonExportableObjects,
missingRefs: collectedMissingRefs,
};
};
@ -126,3 +168,83 @@ const fetchReferences = async ({
.map((obj) => ({ type: obj.type, id: obj.id })),
};
};
const buildTransforms = (typeRegistry: ISavedObjectTypeRegistry) =>
typeRegistry.getAllTypes().reduce((transformMap, type) => {
if (type.management?.onExport) {
transformMap.set(type.name, type.management.onExport);
}
return transformMap;
}, new Map<string, SavedObjectsExportTransform>());
const buildIsExportable = (
typeRegistry: ISavedObjectTypeRegistry
): SavedObjectsExportablePredicate<any> => {
const exportablePerType = typeRegistry.getAllTypes().reduce((exportableMap, type) => {
if (type.management?.isExportable) {
exportableMap.set(type.name, type.management.isExportable);
}
return exportableMap;
}, new Map<string, SavedObjectsExportablePredicate>());
return (obj: SavedObject) => {
const typePredicate = exportablePerType.get(obj.type);
return typePredicate ? typePredicate(obj) : true;
};
};
const splitByExportability = (
objects: SavedObject[],
isExportable: SavedObjectsExportablePredicate<any>,
logger: Logger
) => {
const exportableObjects: SavedObject[] = [];
const nonExportableObjects: ExcludedObject[] = [];
objects.forEach((obj) => {
try {
const exportable = isExportable(obj);
if (exportable) {
exportableObjects.push(obj);
} else {
nonExportableObjects.push({
id: obj.id,
type: obj.type,
reason: 'excluded',
});
}
} catch (e) {
logger.error(
`Error invoking "isExportable" for object ${obj.type}:${obj.id}. Error was: ${
e.stack ?? e.message
}`
);
nonExportableObjects.push({
id: obj.id,
type: obj.type,
reason: 'predicate_error',
});
}
});
return {
exportable: exportableObjects,
nonExportable: nonExportableObjects,
};
};
const splitByKeys = (objects: SavedObject[], keys: ObjectKey[]) => {
const included: SavedObject[] = [];
const excluded: SavedObject[] = [];
objects.forEach((obj) => {
if (keys.includes(objKey(obj))) {
included.push(obj);
} else {
excluded.push(obj);
}
});
return {
included,
excluded,
};
};

View file

@ -13,6 +13,7 @@ export type {
SavedObjectsExportResultDetails,
SavedObjectsExportTransformContext,
SavedObjectsExportTransform,
SavedObjectsExportExcludedObject,
} from './types';
export { SavedObjectsExporter } from './saved_objects_exporter';
export type { ISavedObjectsExporter } from './saved_objects_exporter';

View file

@ -77,32 +77,34 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@ -185,6 +187,8 @@ describe('getSortedObjectsForExport()', () => {
expect(savedObjectsClient.find).toHaveBeenCalledTimes(1);
expect(response[response.length - 1]).toMatchInlineSnapshot(`
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 20,
"missingRefCount": 0,
"missingReferences": Array [],
@ -269,6 +273,8 @@ describe('getSortedObjectsForExport()', () => {
expect(savedObjectsClient.find).toHaveBeenCalledTimes(2);
expect(response[response.length - 1]).toMatchInlineSnapshot(`
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 1500,
"missingRefCount": 0,
"missingReferences": Array [],
@ -422,32 +428,34 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@ -579,32 +587,34 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@ -674,26 +684,28 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"exportedCount": 1,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
Array [
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 1,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@ -770,32 +782,34 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@ -929,38 +943,40 @@ describe('getSortedObjectsForExport()', () => {
});
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {
"name": "foo",
},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {
"name": "bar",
},
"id": "2",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {
"name": "baz",
},
"id": "3",
"references": Array [],
"type": "index-pattern",
},
Object {
"exportedCount": 3,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
Array [
Object {
"attributes": Object {
"name": "foo",
},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {
"name": "bar",
},
"id": "2",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {
"name": "baz",
},
"id": "3",
"references": Array [],
"type": "index-pattern",
},
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 3,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
});
});
@ -1003,32 +1019,34 @@ describe('getSortedObjectsForExport()', () => {
});
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@ -1211,32 +1229,34 @@ describe('getSortedObjectsForExport()', () => {
});
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "name",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"excludedObjects": Array [],
"excludedObjectsCount": 0,
"exportedCount": 2,
"missingRefCount": 0,
"missingReferences": Array [],
},
]
`);
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [

View file

@ -18,7 +18,6 @@ import {
SavedObjectExportBaseOptions,
SavedObjectsExportByObjectOptions,
SavedObjectsExportByTypeOptions,
SavedObjectsExportTransform,
} from './types';
import { SavedObjectsExportError } from './errors';
import { collectExportedObjects } from './collect_exported_objects';
@ -34,8 +33,8 @@ export type ISavedObjectsExporter = PublicMethodsOf<SavedObjectsExporter>;
*/
export class SavedObjectsExporter {
readonly #savedObjectsClient: SavedObjectsClientContract;
readonly #exportTransforms: Record<string, SavedObjectsExportTransform>;
readonly #exportSizeLimit: number;
readonly #typeRegistry: ISavedObjectTypeRegistry;
readonly #log: Logger;
constructor({
@ -52,15 +51,7 @@ export class SavedObjectsExporter {
this.#log = logger;
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>);
this.#typeRegistry = typeRegistry;
}
/**
@ -121,13 +112,15 @@ export class SavedObjectsExporter {
const {
objects: collectedObjects,
missingRefs: missingReferences,
excludedObjects,
} = await collectExportedObjects({
objects: savedObjects,
includeReferences: includeReferencesDeep,
namespace,
request,
exportTransforms: this.#exportTransforms,
typeRegistry: this.#typeRegistry,
savedObjectsClient: this.#savedObjectsClient,
logger: this.#log,
});
// sort with the provided sort function then with the default export sorting
@ -142,6 +135,8 @@ export class SavedObjectsExporter {
exportedCount: exportedObjects.length,
missingRefCount: missingReferences.length,
missingReferences,
excludedObjectsCount: excludedObjects.length,
excludedObjects,
};
this.#log.debug(`Exporting [${redactedObjects.length}] saved objects.`);
return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]);

View file

@ -72,6 +72,20 @@ export interface SavedObjectsExportResultDetails {
/** the missing reference type. */
type: string;
}>;
/** number of objects that were excluded from the export */
excludedObjectsCount: number;
/** excluded objects details */
excludedObjects: SavedObjectsExportExcludedObject[];
}
/** @public */
export interface SavedObjectsExportExcludedObject {
/** id of the excluded object */
id: string;
/** type of the excluded object */
type: string;
/** optional cause of the exclusion */
reason?: string;
}
/**
@ -158,7 +172,7 @@ export interface SavedObjectsExportTransformContext {
*
* @public
*/
export type SavedObjectsExportTransform = <T = unknown>(
export type SavedObjectsExportTransform<T = unknown> = (
context: SavedObjectsExportTransformContext,
objects: Array<SavedObject<T>>
) => SavedObject[] | Promise<SavedObject[]>;

View file

@ -41,6 +41,7 @@ export type {
SavedObjectsExportError,
SavedObjectsExportTransformContext,
SavedObjectsExportTransform,
SavedObjectsExportExcludedObject,
} from './export';
export { SavedObjectsSerializer } from './serialization';

View file

@ -141,7 +141,7 @@ export interface SavedObjectsServiceSetup {
* }
* ```
*/
registerType: (type: SavedObjectsType) => void;
registerType: <Attributes = any>(type: SavedObjectsType<Attributes>) => void;
}
/**

View file

@ -253,7 +253,7 @@ export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolat
*
* @public
*/
export interface SavedObjectsType {
export interface SavedObjectsType<Attributes = any> {
/**
* The name of the type, which is also used as the internal id.
*/
@ -337,7 +337,7 @@ export interface SavedObjectsType {
/**
* An optional {@link SavedObjectsTypeManagementDefinition | saved objects management section} definition for the type.
*/
management?: SavedObjectsTypeManagementDefinition;
management?: SavedObjectsTypeManagementDefinition<Attributes>;
}
/**
@ -345,7 +345,7 @@ export interface SavedObjectsType {
*
* @public
*/
export interface SavedObjectsTypeManagementDefinition {
export interface SavedObjectsTypeManagementDefinition<Attributes = any> {
/**
* Is the type importable or exportable. Defaults to `false`.
*/
@ -363,12 +363,12 @@ export interface SavedObjectsTypeManagementDefinition {
* 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.
*/
getTitle?: (savedObject: SavedObject<any>) => string;
getTitle?: (savedObject: SavedObject<Attributes>) => string;
/**
* Function returning the url to use to redirect to the editing page of this object.
* If not defined, editing will not be allowed.
*/
getEditUrl?: (savedObject: SavedObject<any>) => string;
getEditUrl?: (savedObject: SavedObject<Attributes>) => string;
/**
* Function returning the url to use to redirect to this object from the management section.
* If not defined, redirecting to the object will not be allowed.
@ -377,7 +377,9 @@ export interface SavedObjectsTypeManagementDefinition {
* the object page, relative to the base path. `uiCapabilitiesPath` is the path to check in the
* {@link Capabilities | uiCapabilities} to check if the user has permission to access the object.
*/
getInAppUrl?: (savedObject: SavedObject<any>) => { path: string; uiCapabilitiesPath: string };
getInAppUrl?: (
savedObject: SavedObject<Attributes>
) => { path: string; uiCapabilitiesPath: string };
/**
* An optional export transform function that can be used transform the objects of the registered type during
* the export process.
@ -386,9 +388,14 @@ export interface SavedObjectsTypeManagementDefinition {
*
* See {@link SavedObjectsExportTransform | the transform type documentation} for more info and examples.
*
* When implementing both `isExportable` and `onExport`, it is mandatory that
* `isExportable` returns the same value for an object before and after going
* though the export transform.
* E.g `isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)`
*
* @remarks `importableAndExportable` must be `true` to specify this property.
*/
onExport?: SavedObjectsExportTransform;
onExport?: SavedObjectsExportTransform<Attributes>;
/**
* An optional {@link SavedObjectsImportHook | import hook} to use when importing given type.
*
@ -431,5 +438,52 @@ export interface SavedObjectsTypeManagementDefinition {
* @remarks messages returned in the warnings are user facing and must be translated.
* @remarks `importableAndExportable` must be `true` to specify this property.
*/
onImport?: SavedObjectsImportHook;
onImport?: SavedObjectsImportHook<Attributes>;
/**
* Optional hook to specify whether an object should be exportable.
*
* If specified, `isExportable` will be called during export for each
* of this type's objects in the export, and the ones not matching the
* predicate will be excluded from the export.
*
* When implementing both `isExportable` and `onExport`, it is mandatory that
* `isExportable` returns the same value for an object before and after going
* though the export transform.
* E.g `isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)`
*
* @example
* Registering a type with a per-object exportability predicate
* ```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,
* isExportable: (object) => {
* if (object.attributes.myCustomAttr === 'foo') {
* return false;
* }
* return true;
* }
* },
* });
* }
* }
* ```
*
* @remarks `importableAndExportable` must be `true` to specify this property.
*/
isExportable?: SavedObjectsExportablePredicate<Attributes>;
}
/**
* @public
*/
export type SavedObjectsExportablePredicate<Attributes = unknown> = (
obj: SavedObject<Attributes>
) => boolean;

View file

@ -2508,8 +2508,17 @@ export class SavedObjectsExportError extends Error {
readonly type: string;
}
// @public (undocumented)
export interface SavedObjectsExportExcludedObject {
id: string;
reason?: string;
type: string;
}
// @public
export interface SavedObjectsExportResultDetails {
excludedObjects: SavedObjectsExportExcludedObject[];
excludedObjectsCount: number;
exportedCount: number;
missingRefCount: number;
missingReferences: Array<{
@ -2519,7 +2528,7 @@ export interface SavedObjectsExportResultDetails {
}
// @public
export type SavedObjectsExportTransform = <T = unknown>(context: SavedObjectsExportTransformContext, objects: Array<SavedObject<T>>) => SavedObject[] | Promise<SavedObject[]>;
export type SavedObjectsExportTransform<T = unknown> = (context: SavedObjectsExportTransformContext, objects: Array<SavedObject<T>>) => SavedObject[] | Promise<SavedObject[]>;
// @public
export interface SavedObjectsExportTransformContext {
@ -2930,7 +2939,7 @@ export class SavedObjectsSerializer {
// @public
export interface SavedObjectsServiceSetup {
addClientWrapper: (priority: number, id: string, factory: SavedObjectsClientWrapperFactory) => void;
registerType: (type: SavedObjectsType) => void;
registerType: <Attributes = any>(type: SavedObjectsType<Attributes>) => void;
setClientFactoryProvider: (clientFactoryProvider: SavedObjectsClientFactoryProvider) => void;
}
@ -2956,12 +2965,12 @@ export interface SavedObjectStatusMeta {
}
// @public (undocumented)
export interface SavedObjectsType {
export interface SavedObjectsType<Attributes = any> {
convertToAliasScript?: string;
convertToMultiNamespaceTypeVersion?: string;
hidden: boolean;
indexPattern?: string;
management?: SavedObjectsTypeManagementDefinition;
management?: SavedObjectsTypeManagementDefinition<Attributes>;
mappings: SavedObjectsTypeMappingDefinition;
migrations?: SavedObjectMigrationMap | (() => SavedObjectMigrationMap);
name: string;
@ -2969,18 +2978,20 @@ export interface SavedObjectsType {
}
// @public
export interface SavedObjectsTypeManagementDefinition {
export interface SavedObjectsTypeManagementDefinition<Attributes = any> {
defaultSearchField?: string;
getEditUrl?: (savedObject: SavedObject<any>) => string;
getInAppUrl?: (savedObject: SavedObject<any>) => {
getEditUrl?: (savedObject: SavedObject<Attributes>) => string;
getInAppUrl?: (savedObject: SavedObject<Attributes>) => {
path: string;
uiCapabilitiesPath: string;
};
getTitle?: (savedObject: SavedObject<any>) => string;
getTitle?: (savedObject: SavedObject<Attributes>) => string;
icon?: string;
importableAndExportable?: boolean;
onExport?: SavedObjectsExportTransform;
onImport?: SavedObjectsImportHook;
// Warning: (ae-forgotten-export) The symbol "SavedObjectsExportablePredicate" needs to be exported by the entry point index.d.ts
isExportable?: SavedObjectsExportablePredicate<Attributes>;
onExport?: SavedObjectsExportTransform<Attributes>;
onImport?: SavedObjectsImportHook<Attributes>;
}
// @public
@ -3045,11 +3056,11 @@ export class SavedObjectsUtils {
// @public
export class SavedObjectTypeRegistry {
getAllTypes(): SavedObjectsType[];
getImportableAndExportableTypes(): SavedObjectsType[];
getAllTypes(): SavedObjectsType<any>[];
getImportableAndExportableTypes(): SavedObjectsType<any>[];
getIndex(type: string): string | undefined;
getType(type: string): SavedObjectsType | undefined;
getVisibleTypes(): SavedObjectsType[];
getType(type: string): SavedObjectsType<any> | undefined;
getVisibleTypes(): SavedObjectsType<any>[];
isHidden(type: string): boolean;
isImportableAndExportable(type: string): boolean;
isMultiNamespace(type: string): boolean;

View file

@ -14,14 +14,22 @@ describe('extractExportDetails', () => {
};
const detailsLine = (
exported: number,
missingRefs: SavedObjectsExportResultDetails['missingReferences'] = []
{
missingRefs = [],
excludedObjects = [],
}: {
missingRefs?: SavedObjectsExportResultDetails['missingReferences'];
excludedObjects?: SavedObjectsExportResultDetails['excludedObjects'];
} = {}
) => {
return (
JSON.stringify({
exportedCount: exported,
missingRefCount: missingRefs.length,
missingReferences: missingRefs,
}) + '\n'
excludedObjectsCount: excludedObjects.length,
excludedObjects,
} as SavedObjectsExportResultDetails) + '\n'
);
};
@ -43,6 +51,8 @@ describe('extractExportDetails', () => {
exportedCount: 3,
missingRefCount: 0,
missingReferences: [],
excludedObjectsCount: 0,
excludedObjects: [],
});
});
@ -51,10 +61,12 @@ describe('extractExportDetails', () => {
[
[
objLine('1', 'index-pattern'),
detailsLine(1, [
{ id: '2', type: 'index-pattern' },
{ id: '3', type: 'index-pattern' },
]),
detailsLine(1, {
missingRefs: [
{ id: '2', type: 'index-pattern' },
{ id: '3', type: 'index-pattern' },
],
}),
].join(''),
],
{
@ -71,6 +83,39 @@ describe('extractExportDetails', () => {
{ id: '2', type: 'index-pattern' },
{ id: '3', type: 'index-pattern' },
],
excludedObjectsCount: 0,
excludedObjects: [],
});
});
it('should properly extract the excluded objects', async () => {
const exportData = new Blob(
[
[
objLine('1', 'index-pattern'),
detailsLine(1, {
excludedObjects: [
{ id: '2', type: 'index-pattern', reason: 'foo' },
{ id: '3', type: 'index-pattern' },
],
}),
].join(''),
],
{
type: 'application/ndjson',
endings: 'transparent',
}
);
const result = await extractExportDetails(exportData);
expect(result).toEqual({
exportedCount: 1,
missingRefCount: 0,
missingReferences: [],
excludedObjectsCount: 2,
excludedObjects: [
{ id: '2', type: 'index-pattern', reason: 'foo' },
{ id: '3', type: 'index-pattern' },
],
});
});

View file

@ -33,6 +33,12 @@ export interface SavedObjectsExportResultDetails {
id: string;
type: string;
}>;
excludedObjectsCount: number;
excludedObjects: Array<{
id: string;
type: string;
reason?: string;
}>;
}
function isExportDetails(object: any): object is SavedObjectsExportResultDetails {

View file

@ -258,7 +258,7 @@ describe('SavedObjectsTable', () => {
});
});
it('should display a warning is export contains missing references', async () => {
it('should display a warning if the export contains missing references', async () => {
const mockSelectedSavedObjects = [
{ id: '1', type: 'index-pattern' },
{ id: '3', type: 'dashboard' },
@ -280,6 +280,8 @@ describe('SavedObjectsTable', () => {
exportedCount: 2,
missingRefCount: 1,
missingReferences: [{ id: '7', type: 'visualisation' }],
excludedObjectsCount: 0,
excludedObjects: [],
}));
const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient });
@ -303,6 +305,53 @@ describe('SavedObjectsTable', () => {
});
});
it('should display a specific message if the export contains excluded objects', async () => {
const mockSelectedSavedObjects = [
{ id: '1', type: 'index-pattern' },
{ id: '3', type: 'dashboard' },
] as SavedObjectWithMetadata[];
const mockSavedObjects = mockSelectedSavedObjects.map((obj) => ({
_id: obj.id,
_source: {},
}));
const mockSavedObjectsClient = {
...defaultProps.savedObjectsClient,
bulkGet: jest.fn().mockImplementation(() => ({
savedObjects: mockSavedObjects,
})),
};
extractExportDetailsMock.mockImplementation(() => ({
exportedCount: 2,
missingRefCount: 0,
missingReferences: [],
excludedObjectsCount: 1,
excludedObjects: [{ id: '7', type: 'visualisation' }],
}));
const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient });
// Ensure all promises resolve
await new Promise((resolve) => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
// Set some as selected
component.instance().onSelectionChanged(mockSelectedSavedObjects);
await component.instance().onExport(true);
expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true);
expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({
title:
'Your file is downloading in the background. ' +
'Some objects were excluded from the export. ' +
'Please see the last line in the exported file for a list of excluded objects.',
});
});
it('should allow the user to choose when exporting all', async () => {
const component = shallowRender();

View file

@ -358,7 +358,7 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
saveAs(blob, 'export.ndjson');
const exportDetails = await extractExportDetails(blob);
this.showExportSuccessMessage(exportDetails);
this.showExportCompleteMessage(exportDetails);
};
onExportAll = async () => {
@ -395,31 +395,45 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
saveAs(blob, 'export.ndjson');
const exportDetails = await extractExportDetails(blob);
this.showExportSuccessMessage(exportDetails);
this.showExportCompleteMessage(exportDetails);
this.setState({ isShowingExportAllOptionsModal: false });
};
showExportSuccessMessage = (exportDetails: SavedObjectsExportResultDetails | undefined) => {
showExportCompleteMessage = (exportDetails: SavedObjectsExportResultDetails | undefined) => {
const { notifications } = this.props;
if (exportDetails && exportDetails.missingReferences.length > 0) {
notifications.toasts.addWarning({
title: i18n.translate(
'savedObjectsManagement.objectsTable.export.successWithMissingRefsNotification',
{
defaultMessage:
'Your file is downloading in the background. ' +
'Some related objects could not be found. ' +
'Please see the last line in the exported file for a list of missing objects.',
}
),
});
} else {
notifications.toasts.addSuccess({
title: i18n.translate('savedObjectsManagement.objectsTable.export.successNotification', {
defaultMessage: 'Your file is downloading in the background',
}),
});
if (exportDetails) {
if (exportDetails.missingReferences.length > 0) {
return notifications.toasts.addWarning({
title: i18n.translate(
'savedObjectsManagement.objectsTable.export.successWithMissingRefsNotification',
{
defaultMessage:
'Your file is downloading in the background. ' +
'Some related objects could not be found. ' +
'Please see the last line in the exported file for a list of missing objects.',
}
),
});
}
if (exportDetails.excludedObjects.length > 0) {
return notifications.toasts.addSuccess({
title: i18n.translate(
'savedObjectsManagement.objectsTable.export.successWithExcludedObjectsNotification',
{
defaultMessage:
'Your file is downloading in the background. ' +
'Some objects were excluded from the export. ' +
'Please see the last line in the exported file for a list of excluded objects.',
}
),
});
}
}
return notifications.toasts.addSuccess({
title: i18n.translate('savedObjectsManagement.objectsTable.export.successNotification', {
defaultMessage: 'Your file is downloading in the background',
}),
});
};
finishImport = () => {

View file

@ -0,0 +1,135 @@
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-is-exportable:1",
"source": {
"test-is-exportable": {
"title": "obj 1",
"enabled": true
},
"type": "test-is-exportable",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": [
{
"type": "test-is-exportable",
"id": "2",
"name": "ref-1"
},
{
"type": "test-is-exportable",
"id": "3",
"name": "ref-2"
}
]
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-is-exportable:2",
"source": {
"test-is-exportable": {
"title": "obj 2",
"enabled": false
},
"type": "test-is-exportable",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": []
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-is-exportable:3",
"source": {
"test-is-exportable": {
"title": "obj 3",
"enabled": true
},
"type": "test-is-exportable",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": [
{
"type": "test-is-exportable",
"id": "4",
"name": "ref-1"
},
{
"type": "test-is-exportable",
"id": "5",
"name": "ref-2"
}
]
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-is-exportable:4",
"source": {
"test-is-exportable": {
"title": "obj 4",
"enabled": false
},
"type": "test-is-exportable",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": []
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-is-exportable:5",
"source": {
"test-is-exportable": {
"title": "obj 5",
"enabled": true
},
"type": "test-is-exportable",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": []
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-is-exportable:error",
"source": {
"test-is-exportable": {
"title": "obj error",
"enabled": true
},
"type": "test-is-exportable",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": []
}
}
}

View file

@ -0,0 +1,505 @@
{
"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-is-exportable": {
"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": {
"dynamic": false,
"properties": {}
},
"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

@ -152,6 +152,30 @@ export class SavedObjectExportTransformsPlugin implements Plugin {
getTitle: (obj) => obj.attributes.title,
},
});
// example of a SO type implementing the `isExportable` API
savedObjects.registerType<{ enabled: boolean; title: string }>({
name: 'test-is-exportable',
hidden: false,
namespaceType: 'single',
mappings: {
properties: {
title: { type: 'text' },
enabled: { type: 'boolean' },
},
},
management: {
defaultSearchField: 'title',
importableAndExportable: true,
getTitle: (obj) => obj.attributes.title,
isExportable: (obj) => {
if (obj.id === 'error') {
throw new Error('something went wrong');
}
return obj.attributes.enabled === true;
},
},
});
}
public start() {}

View file

@ -8,6 +8,7 @@
import expect from '@kbn/expect';
import type { SavedObject } from '../../../../src/core/types';
import type { SavedObjectsExportResultDetails } from '../../../../src/core/server';
import { PluginFunctionalProviderContext } from '../../services';
function parseNdJson(input: string): Array<SavedObject<any>> {
@ -139,7 +140,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
});
});
describe('FOO nested export transforms', () => {
describe('nested export transforms', () => {
before(async () => {
await esArchiver.load(
'test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform'
@ -183,5 +184,121 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
});
});
});
describe('isExportable API', () => {
before(async () => {
await esArchiver.load(
'test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion'
);
});
after(async () => {
await esArchiver.unload(
'test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion'
);
});
it('should only export objects returning `true` for `isExportable`', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
objects: [
{
type: 'test-is-exportable',
id: '1',
},
],
includeReferencesDeep: true,
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text).sort((obj1, obj2) =>
obj1.id.localeCompare(obj2.id)
);
expect(objects.map((obj) => `${obj.type}:${obj.id}`)).to.eql([
'test-is-exportable:1',
'test-is-exportable:3',
'test-is-exportable:5',
]);
});
});
it('lists objects that got filtered', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
objects: [
{
type: 'test-is-exportable',
id: '1',
},
],
includeReferencesDeep: true,
excludeExportDetails: false,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
const exportDetails = (objects[
objects.length - 1
] as unknown) as SavedObjectsExportResultDetails;
expect(exportDetails.excludedObjectsCount).to.eql(2);
expect(exportDetails.excludedObjects).to.eql([
{
type: 'test-is-exportable',
id: '2',
reason: 'excluded',
},
{
type: 'test-is-exportable',
id: '4',
reason: 'excluded',
},
]);
});
});
it('excludes objects if `isExportable` throws', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
objects: [
{
type: 'test-is-exportable',
id: '5',
},
{
type: 'test-is-exportable',
id: 'error',
},
],
includeReferencesDeep: true,
excludeExportDetails: false,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.length).to.eql(2);
expect([objects[0]].map((obj) => `${obj.type}:${obj.id}`)).to.eql([
'test-is-exportable:5',
]);
const exportDetails = (objects[
objects.length - 1
] as unknown) as SavedObjectsExportResultDetails;
expect(exportDetails.excludedObjects).to.eql([
{
type: 'test-is-exportable',
id: 'error',
reason: 'predicate_error',
},
]);
});
});
});
});
}

View file

@ -21,12 +21,15 @@ const {
export interface ExportTestDefinition extends TestDefinition {
request: ReturnType<typeof createRequest>;
}
export type ExportTestSuite = TestSuite<ExportTestDefinition>;
interface SuccessResult {
type: string;
id: string;
originId?: string;
}
export interface ExportTestCase {
title: string;
type: string;
@ -135,7 +138,13 @@ export const createRequest = ({ type, id }: ExportTestCase) =>
const getTestTitle = ({ failure, title }: ExportTestCase) =>
`${failure?.reason || 'success'} ["${title}"]`;
const EMPTY_RESULT = { exportedCount: 0, missingRefCount: 0, missingReferences: [] };
const EMPTY_RESULT = {
excludedObjects: [],
excludedObjectsCount: 0,
exportedCount: 0,
missingRefCount: 0,
missingReferences: [],
};
export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const expectSavedObjectForbiddenBulkGet = expectResponses.forbiddenTypes('bulk_get');
@ -189,6 +198,8 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
exportedCount: ndjson.length - 1,
missingRefCount: 0,
missingReferences: [],
excludedObjectsCount: 0,
excludedObjects: [],
});
}
};