[data views] swap_references api improvements (#163225)

## Summary

Some simple dev UX improvements to the swap_references data views api - 

```
POST /api/data_views/swap_references/_preview
{
     "fromId" : "abcd-efg",
     "toId" : "xyz-123"
}

returns 
{
  result: [{ id: "123", type: "visualization" }],
}
```


```
POST /api/data_views/swap_references
{
     "fromId" : "abcd-efg",
     "toId" : "xyz-123",
     "delete" : true // optional, removes data view which is no longer referenced
}

returns 
{
  result: [{ id: "123", type: "visualization" }],
  deleteStatus: {
    remainingRefs: 0,
    deletePerformed: true
}
```

Additional params - 

```
fromType: string - specify the saved object type. Default is `index-pattern` for data view
forId: string | string[] - limit the affected saved objects to one or more by id
forType: string - limit the affected saved objects by type
```

Improves upon https://github.com/elastic/kibana/pull/157665

Docs will be created in follow up PR
This commit is contained in:
Matthew Kime 2023-08-11 20:56:46 -05:00 committed by GitHub
parent d59d778555
commit cafaa9295e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 172 additions and 143 deletions

View file

@ -46,7 +46,8 @@ const routes = [
updateRoutes.registerUpdateDataViewRoute,
updateRoutes.registerUpdateDataViewRouteLegacy,
...Object.values(scriptedRoutes),
swapReferencesRoute,
swapReferencesRoute({ previewRoute: false }),
swapReferencesRoute({ previewRoute: true }),
];
export { routes };

View file

@ -27,8 +27,10 @@ interface GetDataViewArgs {
interface SwapRefResponse {
result: Array<{ id: string; type: string }>;
preview: boolean;
deleteSuccess?: boolean;
deleteStatus?: {
remainingRefs: number;
deletePerformed: boolean;
};
}
export const swapReference = async ({
@ -43,135 +45,147 @@ export const swapReference = async ({
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: {
export const swapReferencesRoute =
({ previewRoute }: { previewRoute: boolean }) =>
(
router: IRouter,
getStartServices: StartServicesAccessor<
DataViewsServerPluginStartDependencies,
DataViewsServerPluginStart
>,
usageCollection?: UsageCounter
) => {
const path = previewRoute
? `${DATA_VIEW_SWAP_REFERENCES_PATH}/_preview`
: DATA_VIEW_SWAP_REFERENCES_PATH;
router.versioned.post({ path, access: 'public' }).addVersion(
{
version: INITIAL_REST_VERSION,
validate: {
request: {
body: schema.object({
result: schema.arrayOf(schema.object({ id: idSchema, type: schema.string() })),
preview: schema.boolean(),
deleteSuccess: schema.maybe(schema.boolean()),
fromId: idSchema,
fromType: schema.maybe(schema.string()),
toId: idSchema,
forId: schema.maybe(schema.oneOf([idSchema, schema.arrayOf(idSchema)])),
forType: schema.maybe(schema.string()),
delete: schema.maybe(schema.boolean()),
}),
},
response: {
200: {
body: schema.object({
result: schema.arrayOf(schema.object({ id: idSchema, type: schema.string() })),
deleteStatus: schema.maybe(
schema.object({
remainingRefs: schema.number(),
deletePerformed: 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;
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.fromType || DATA_VIEW_SAVED_OBJECT_TYPE;
const searchId =
!Array.isArray(req.body.forId) && req.body.forId !== undefined
? [req.body.forId]
: req.body.forId;
usageCollection?.incrementCounter({ counterName: 'swap_references' });
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}`);
}
// verify 'to' object actually exists
try {
await savedObjectsClient.get(type, req.body.toId);
} catch (e) {
throw new Error(`Could not find object with type ${type} and id ${req.body.toId}`);
}
// assemble search params
const findParams: SavedObjectsFindOptions = {
type: types.map((t) => t.name),
hasReference: { type, id: req.body.from_id },
};
// assemble search params
const findParams: SavedObjectsFindOptions = {
type: types.map((t) => t.name),
hasReference: { type, id: req.body.fromId },
};
if (req.body.for_type) {
findParams.type = [req.body.for_type];
}
if (req.body.forType) {
findParams.type = [req.body.forType];
}
const { saved_objects: savedObjects } = await savedObjectsClient.find(findParams);
const { saved_objects: savedObjects } = await savedObjectsClient.find(findParams);
const filteredSavedObjects = searchId
? savedObjects.filter((so) => searchId?.includes(so.id))
: savedObjects;
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,
}));
// create summary of affected objects
const resultSummary = filteredSavedObjects.map((savedObject) => ({
id: savedObject.id,
type: savedObject.type,
}));
const body: SwapRefResponse = {
result: resultSummary,
preview,
};
const body: SwapRefResponse = {
result: resultSummary,
};
// bail if preview
if (previewRoute) {
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.fromId) {
return { ...ref, id: req.body.toId };
} 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.deleteStatus = {
remainingRefs: verifyNoMoreRefs.total,
deletePerformed: false,
};
} else {
await savedObjectsClient.delete(type, req.body.fromId, { refresh: 'wait_for' });
body.deleteStatus = {
remainingRefs: verifyNoMoreRefs.total,
deletePerformed: true,
};
}
}
// 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,6 +20,7 @@ export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const title = 'logs-*';
const prevDataViewId = '91200a00-9efd-11e7-acb3-3dab96693fab';
const PREVIEW_PATH = `${DATA_VIEW_SWAP_REFERENCES_PATH}/_preview`;
let dataViewId = '';
describe('main', () => {
@ -49,23 +50,23 @@ export default function ({ getService }: FtrProviderContext) {
it('can preview', async () => {
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.post(PREVIEW_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
from_id: prevDataViewId,
to_id: dataViewId,
fromId: prevDataViewId,
toId: dataViewId,
});
expect(res).to.have.property('status', 200);
});
it('can preview specifying type', async () => {
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.post(PREVIEW_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
from_id: prevDataViewId,
from_type: 'index-pattern',
to_id: dataViewId,
fromId: prevDataViewId,
fromType: 'index-pattern',
toId: dataViewId,
});
expect(res).to.have.property('status', 200);
});
@ -75,13 +76,11 @@ export default function ({ getService }: FtrProviderContext) {
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
from_id: prevDataViewId,
to_id: dataViewId,
preview: false,
fromId: prevDataViewId,
toId: dataViewId,
});
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');
});
@ -91,13 +90,14 @@ export default function ({ getService }: FtrProviderContext) {
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
from_id: prevDataViewId,
to_id: dataViewId,
preview: false,
fromId: prevDataViewId,
toId: dataViewId,
delete: true,
});
expect(res).to.have.property('status', 200);
expect(res.body.result.length).to.equal(1);
expect(res.body.deleteStatus.remainingRefs).to.equal(0);
expect(res.body.deleteStatus.deletePerformed).to.equal(true);
const res2 = await supertest
.get(SPECIFIC_DATA_VIEW_PATH.replace('{id}', prevDataViewId))
@ -118,13 +118,29 @@ export default function ({ getService }: FtrProviderContext) {
);
});
it("won't delete if reference remains", async () => {
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION)
.send({
fromId: '8963ca30-3224-11e8-a572-ffca06da1357',
toId: '91200a00-9efd-11e7-acb3-3dab96693fab',
forId: ['960372e0-3224-11e8-a572-ffca06da1357'],
delete: true,
});
expect(res).to.have.property('status', 200);
expect(res.body.result.length).to.equal(1);
expect(res.body.deleteStatus.remainingRefs).to.equal(1);
expect(res.body.deleteStatus.deletePerformed).to.equal(false);
});
it('can limit by id', async () => {
// confirm this will find two items
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.post(PREVIEW_PATH)
.send({
from_id: '8963ca30-3224-11e8-a572-ffca06da1357',
to_id: '91200a00-9efd-11e7-acb3-3dab96693fab',
fromId: '8963ca30-3224-11e8-a572-ffca06da1357',
toId: '91200a00-9efd-11e7-acb3-3dab96693fab',
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res).to.have.property('status', 200);
@ -134,10 +150,9 @@ export default function ({ getService }: FtrProviderContext) {
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,
fromId: '8963ca30-3224-11e8-a572-ffca06da1357',
toId: '91200a00-9efd-11e7-acb3-3dab96693fab',
forId: ['960372e0-3224-11e8-a572-ffca06da1357'],
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res2).to.have.property('status', 200);
@ -147,10 +162,10 @@ export default function ({ getService }: FtrProviderContext) {
it('can limit by type', async () => {
// confirm this will find two items
const res = await supertest
.post(DATA_VIEW_SWAP_REFERENCES_PATH)
.post(PREVIEW_PATH)
.send({
from_id: '8963ca30-3224-11e8-a572-ffca06da1357',
to_id: '91200a00-9efd-11e7-acb3-3dab96693fab',
fromId: '8963ca30-3224-11e8-a572-ffca06da1357',
toId: '91200a00-9efd-11e7-acb3-3dab96693fab',
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res).to.have.property('status', 200);
@ -160,10 +175,9 @@ export default function ({ getService }: FtrProviderContext) {
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,
fromId: '8963ca30-3224-11e8-a572-ffca06da1357',
toId: '91200a00-9efd-11e7-acb3-3dab96693fab',
forType: 'search',
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
expect(res2).to.have.property('status', 200);