[data views] REST endpoint for swapping saved object references (#157665)

## Summary

Managing large number of saved objects can be cumbersome. This api
endpoint allows the management of references without clicking through a
lot of different UIs.

For example - 

This swaps all data view id `abcd-efg` references to `xyz-123`

```
POST /api/data_views/swap_references
{
     "from_id" : "abcd-efg",
     "to_id" : "xyz-123",
     "preview" : false, // optional, necessary to save changes
     "delete" : true // optional, removes data view which is no longer referenced
}

returns 
{
  preview: false,
  result: [{ id: "123", type: "visualization" }],
  deleteSuccess: true
}
```

Additional params - 
```
from_type: string - specify the saved object type. Default is `index-pattern` for data view
for_id: string | string[] - limit the affected saved objects to one or more by id
for_type: string - limit the affected saved objects by type
```



Closes https://github.com/elastic/kibana/issues/153806
This commit is contained in:
Matthew Kime 2023-07-06 08:29:01 -05:00 committed by GitHub
parent 885e4bf13d
commit c843c97193
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 416 additions and 0 deletions

View file

@ -64,6 +64,11 @@ export const SPECIFIC_SCRIPTED_FIELD_PATH = `${SCRIPTED_FIELD_PATH}/{name}`;
*/
export const SPECIFIC_SCRIPTED_FIELD_PATH_LEGACY = `${SCRIPTED_FIELD_PATH_LEGACY}/{name}`;
/**
* Path to swap references
*/
export const DATA_VIEW_SWAP_REFERENCES_PATH = `${SERVICE_PATH}/swap_references`;
/**
* name of service in path form
*/

View file

@ -52,6 +52,7 @@ export {
SPECIFIC_SCRIPTED_FIELD_PATH_LEGACY,
SERVICE_KEY,
SERVICE_KEY_LEGACY,
DATA_VIEW_SWAP_REFERENCES_PATH,
} from './constants';
export type { SERVICE_KEY_TYPE } from './constants';

View file

@ -17,6 +17,7 @@ import * as getRoutes from './get_data_view';
import * as getAllRoutes from './get_data_views';
import * as hasRoutes from './has_user_data_view';
import * as updateRoutes from './update_data_view';
import { swapReferencesRoute } from './swap_references';
const routes = [
fieldRoutes.registerUpdateFieldsRoute,
@ -45,6 +46,7 @@ const routes = [
updateRoutes.registerUpdateDataViewRoute,
updateRoutes.registerUpdateDataViewRouteLegacy,
...Object.values(scriptedRoutes),
swapReferencesRoute,
];
export { routes };

View file

@ -0,0 +1,177 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { UsageCounter } from '@kbn/usage-collection-plugin/server';
import { schema } from '@kbn/config-schema';
import { IRouter, StartServicesAccessor, SavedObjectsFindOptions } from '@kbn/core/server';
import { DataViewsService } from '../../../common';
import { handleErrors } from './util/handle_errors';
import type {
DataViewsServerPluginStartDependencies,
DataViewsServerPluginStart,
} from '../../types';
import { DATA_VIEW_SWAP_REFERENCES_PATH, INITIAL_REST_VERSION } from '../../constants';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../common/constants';
interface GetDataViewArgs {
dataViewsService: DataViewsService;
usageCollection?: UsageCounter;
counterName: string;
id: string;
}
interface SwapRefResponse {
result: Array<{ id: string; type: string }>;
preview: boolean;
deleteSuccess?: boolean;
}
export const swapReference = async ({
dataViewsService,
usageCollection,
counterName,
id,
}: GetDataViewArgs) => {
usageCollection?.incrementCounter({ counterName });
return dataViewsService.get(id);
};
const idSchema = schema.string();
export const swapReferencesRoute = (
router: IRouter,
getStartServices: StartServicesAccessor<
DataViewsServerPluginStartDependencies,
DataViewsServerPluginStart
>,
usageCollection?: UsageCounter
) => {
router.versioned.post({ path: DATA_VIEW_SWAP_REFERENCES_PATH, access: 'public' }).addVersion(
{
version: INITIAL_REST_VERSION,
validate: {
request: {
body: schema.object({
from_id: idSchema,
from_type: schema.maybe(schema.string()),
to_id: idSchema,
for_id: schema.maybe(schema.oneOf([idSchema, schema.arrayOf(idSchema)])),
for_type: schema.maybe(schema.string()),
preview: schema.maybe(schema.boolean()),
delete: schema.maybe(schema.boolean()),
}),
},
response: {
200: {
body: schema.object({
result: schema.arrayOf(schema.object({ id: idSchema, type: schema.string() })),
preview: schema.boolean(),
deleteSuccess: schema.maybe(schema.boolean()),
}),
},
},
},
},
router.handleLegacyErrors(
handleErrors(async (ctx, req, res) => {
const savedObjectsClient = (await ctx.core).savedObjects.client;
const [core] = await getStartServices();
const types = core.savedObjects.getTypeRegistry().getAllTypes();
const type = req.body.from_type || DATA_VIEW_SAVED_OBJECT_TYPE;
const preview = req.body.preview !== undefined ? req.body.preview : true;
const searchId =
!Array.isArray(req.body.for_id) && req.body.for_id !== undefined
? [req.body.for_id]
: req.body.for_id;
usageCollection?.incrementCounter({ counterName: 'swap_references' });
// verify 'to' object actually exists
try {
await savedObjectsClient.get(type, req.body.to_id);
} catch (e) {
throw new Error(`Could not find object with type ${type} and id ${req.body.to_id}`);
}
// assemble search params
const findParams: SavedObjectsFindOptions = {
type: types.map((t) => t.name),
hasReference: { type, id: req.body.from_id },
};
if (req.body.for_type) {
findParams.type = [req.body.for_type];
}
const { saved_objects: savedObjects } = await savedObjectsClient.find(findParams);
const filteredSavedObjects = searchId
? savedObjects.filter((so) => searchId?.includes(so.id))
: savedObjects;
// create summary of affected objects
const resultSummary = filteredSavedObjects.map((savedObject) => ({
id: savedObject.id,
type: savedObject.type,
}));
const body: SwapRefResponse = {
result: resultSummary,
preview,
};
// bail if preview
if (preview) {
return res.ok({
headers: {
'content-type': 'application/json',
},
body,
});
}
// iterate over list and update references
for (const savedObject of filteredSavedObjects) {
const updatedRefs = savedObject.references.map((ref) => {
if (ref.type === type && ref.id === req.body.from_id) {
return { ...ref, id: req.body.to_id };
} else {
return ref;
}
});
await savedObjectsClient.update(
savedObject.type,
savedObject.id,
{},
{
references: updatedRefs,
}
);
}
if (req.body.delete) {
const verifyNoMoreRefs = await savedObjectsClient.find(findParams);
if (verifyNoMoreRefs.total > 0) {
body.deleteSuccess = false;
} else {
await savedObjectsClient.delete(type, req.body.from_id);
body.deleteSuccess = true;
}
}
return res.ok({
headers: {
'content-type': 'application/json',
},
body,
});
})
)
);
};

View file

@ -20,5 +20,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./integration'));
loadTestFile(require.resolve('./deprecations'));
loadTestFile(require.resolve('./has_user_index_pattern'));
loadTestFile(require.resolve('./swap_references'));
});
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import { INITIAL_REST_VERSION } from '@kbn/data-views-plugin/server/constants';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { dataViewConfig } from '../constants';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('errors', () => {
it('returns 404 error on non-existing index_pattern', async () => {
const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`;
const response = await supertest
.get(`${dataViewConfig.path}/${id}`)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(response.status).to.be(404);
});
it('returns error when ID is too long', async () => {
const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`;
const response = await supertest
.get(`${dataViewConfig.path}/${id}`)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(response.status).to.be(400);
expect(response.body.message).to.be(
'[request params.id]: value has length [1759] but it must have a maximum length of [1000].'
);
});
});
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('swap_references', () => {
loadTestFile(require.resolve('./errors'));
loadTestFile(require.resolve('./main'));
});
}

View file

@ -0,0 +1,174 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import {
DATA_VIEW_SWAP_REFERENCES_PATH,
SPECIFIC_DATA_VIEW_PATH,
DATA_VIEW_PATH,
} from '@kbn/data-views-plugin/server';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import { INITIAL_REST_VERSION } from '@kbn/data-views-plugin/server/constants';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const title = 'logs-*';
const prevDataViewId = '91200a00-9efd-11e7-acb3-3dab96693fab';
let dataViewId = '';
describe('main', () => {
const kibanaServer = getService('kibanaServer');
before(async () => {
const result = await supertest
.post(DATA_VIEW_PATH)
.send({ data_view: { title } })
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
dataViewId = result.body.data_view.id;
});
after(async () => {
await supertest
.delete(SPECIFIC_DATA_VIEW_PATH.replace('{id}', dataViewId))
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
});
beforeEach(async () => {
await kibanaServer.importExport.load(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
afterEach(async () => {
await kibanaServer.importExport.unload(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
it('can preview', async () => {
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
from_id: prevDataViewId,
to_id: dataViewId,
});
expect(res).to.have.property('status', 200);
});
it('can preview specifying type', async () => {
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
from_id: prevDataViewId,
from_type: 'index-pattern',
to_id: dataViewId,
});
expect(res).to.have.property('status', 200);
});
it('can save changes', async () => {
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
from_id: prevDataViewId,
to_id: dataViewId,
preview: false,
});
expect(res).to.have.property('status', 200);
expect(res.body.result.length).to.equal(1);
expect(res.body.preview).to.equal(false);
expect(res.body.result[0].id).to.equal('dd7caf20-9efd-11e7-acb3-3dab96693fab');
expect(res.body.result[0].type).to.equal('visualization');
});
it('can save changes and remove old saved object', async () => {
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
from_id: prevDataViewId,
to_id: dataViewId,
preview: false,
delete: true,
});
expect(res).to.have.property('status', 200);
expect(res.body.result.length).to.equal(1);
const res2 = await supertest
.get(SPECIFIC_DATA_VIEW_PATH.replace('{id}', prevDataViewId))
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res2).to.have.property('statusCode', 404);
});
describe('limit affected saved objects', () => {
beforeEach(async () => {
await kibanaServer.importExport.load(
'test/api_integration/fixtures/kbn_archiver/management/saved_objects/relationships.json'
);
});
afterEach(async () => {
await kibanaServer.importExport.unload(
'test/api_integration/fixtures/kbn_archiver/management/saved_objects/relationships.json'
);
});
it('can limit by id', async () => {
// confirm this will find two items
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.send({
from_id: '8963ca30-3224-11e8-a572-ffca06da1357',
to_id: '91200a00-9efd-11e7-acb3-3dab96693fab',
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res).to.have.property('status', 200);
expect(res.body.result.length).to.equal(2);
// limit to one item
const res2 = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.send({
from_id: '8963ca30-3224-11e8-a572-ffca06da1357',
to_id: '91200a00-9efd-11e7-acb3-3dab96693fab',
for_id: ['960372e0-3224-11e8-a572-ffca06da1357'],
preview: false,
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res2).to.have.property('status', 200);
expect(res2.body.result.length).to.equal(1);
});
it('can limit by type', async () => {
// confirm this will find two items
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.send({
from_id: '8963ca30-3224-11e8-a572-ffca06da1357',
to_id: '91200a00-9efd-11e7-acb3-3dab96693fab',
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res).to.have.property('status', 200);
expect(res.body.result.length).to.equal(2);
// limit to one item
const res2 = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.send({
from_id: '8963ca30-3224-11e8-a572-ffca06da1357',
to_id: '91200a00-9efd-11e7-acb3-3dab96693fab',
for_type: 'search',
preview: false,
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res2).to.have.property('status', 200);
expect(res2.body.result.length).to.equal(1);
});
});
});
}