forked from opensearch-project/OpenSearch-Dashboards
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add copy saved objects API (opensearch-project#217)
* Add copy saved objects API Signed-off-by: gaobinlong <gbinlong@amazon.com> * Modify file header Signed-off-by: gaobinlong <gbinlong@amazon.com> --------- Signed-off-by: gaobinlong <gbinlong@amazon.com>
- Loading branch information
1 parent
116b5fe
commit da80915
Showing
3 changed files
with
338 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
/* | ||
* Copyright OpenSearch Contributors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
import { schema } from '@osd/config-schema'; | ||
import { IRouter } from '../../http'; | ||
import { SavedObjectConfig } from '../saved_objects_config'; | ||
import { exportSavedObjectsToStream } from '../export'; | ||
import { importSavedObjectsFromStream } from '../import'; | ||
|
||
export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => { | ||
const { maxImportExportSize } = config; | ||
|
||
router.post( | ||
{ | ||
path: '/_copy', | ||
validate: { | ||
body: schema.object({ | ||
objects: schema.arrayOf( | ||
schema.object({ | ||
type: schema.string(), | ||
id: schema.string(), | ||
}) | ||
), | ||
includeReferencesDeep: schema.boolean({ defaultValue: false }), | ||
targetWorkspace: schema.string(), | ||
}), | ||
}, | ||
}, | ||
router.handleLegacyErrors(async (context, req, res) => { | ||
const savedObjectsClient = context.core.savedObjects.client; | ||
const { objects, includeReferencesDeep, targetWorkspace } = req.body; | ||
|
||
// need to access the registry for type validation, can't use the schema for this | ||
const supportedTypes = context.core.savedObjects.typeRegistry | ||
.getImportableAndExportableTypes() | ||
.map((t) => t.name); | ||
|
||
const invalidObjects = objects.filter((obj) => !supportedTypes.includes(obj.type)); | ||
if (invalidObjects.length) { | ||
return res.badRequest({ | ||
body: { | ||
message: `Trying to copy object(s) with unsupported types: ${invalidObjects | ||
.map((obj) => `${obj.type}:${obj.id}`) | ||
.join(', ')}`, | ||
}, | ||
}); | ||
} | ||
|
||
const objectsListStream = await exportSavedObjectsToStream({ | ||
savedObjectsClient, | ||
objects, | ||
exportSizeLimit: maxImportExportSize, | ||
includeReferencesDeep, | ||
excludeExportDetails: true, | ||
}); | ||
|
||
const result = await importSavedObjectsFromStream({ | ||
savedObjectsClient: context.core.savedObjects.client, | ||
typeRegistry: context.core.savedObjects.typeRegistry, | ||
readStream: objectsListStream, | ||
objectLimit: maxImportExportSize, | ||
overwrite: false, | ||
createNewCopies: true, | ||
workspaces: [targetWorkspace], | ||
}); | ||
|
||
return res.ok({ body: result }); | ||
}) | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
264 changes: 264 additions & 0 deletions
264
src/core/server/saved_objects/routes/integration_tests/copy.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,264 @@ | ||
/* | ||
* Copyright OpenSearch Contributors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
import * as exportMock from '../../export'; | ||
import { createListStream } from '../../../utils/streams'; | ||
import { mockUuidv4 } from '../../import/__mocks__'; | ||
import supertest from 'supertest'; | ||
import { UnwrapPromise } from '@osd/utility-types'; | ||
import { registerCopyRoute } from '../copy'; | ||
import { savedObjectsClientMock } from '../../../../../core/server/mocks'; | ||
import { SavedObjectConfig } from '../../saved_objects_config'; | ||
import { setupServer, createExportableType } from '../test_utils'; | ||
import { SavedObjectsErrorHelpers } from '../..'; | ||
|
||
jest.mock('../../export', () => ({ | ||
exportSavedObjectsToStream: jest.fn(), | ||
})); | ||
|
||
type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>; | ||
|
||
const { v4: uuidv4 } = jest.requireActual('uuid'); | ||
const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; | ||
const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; | ||
const URL = '/internal/saved_objects/_copy'; | ||
const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; | ||
|
||
describe(`POST ${URL}`, () => { | ||
let server: SetupServerReturn['server']; | ||
let httpSetup: SetupServerReturn['httpSetup']; | ||
let handlerContext: SetupServerReturn['handlerContext']; | ||
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>; | ||
|
||
const emptyResponse = { saved_objects: [], total: 0, per_page: 0, page: 0 }; | ||
const mockIndexPattern = { | ||
type: 'index-pattern', | ||
id: 'my-pattern', | ||
attributes: { title: 'my-pattern-*' }, | ||
references: [], | ||
}; | ||
const mockVisualization = { | ||
type: 'visualization', | ||
id: 'my-visualization', | ||
attributes: { title: 'Test visualization' }, | ||
references: [ | ||
{ | ||
name: 'ref_0', | ||
type: 'index-pattern', | ||
id: 'my-pattern', | ||
}, | ||
], | ||
}; | ||
const mockDashboard = { | ||
type: 'dashboard', | ||
id: 'my-dashboard', | ||
attributes: { title: 'Look at my dashboard' }, | ||
references: [], | ||
}; | ||
|
||
beforeEach(async () => { | ||
mockUuidv4.mockReset(); | ||
mockUuidv4.mockImplementation(() => uuidv4()); | ||
({ server, httpSetup, handlerContext } = await setupServer()); | ||
handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( | ||
allowedTypes.map(createExportableType) | ||
); | ||
handlerContext.savedObjects.typeRegistry.getType.mockImplementation( | ||
(type: string) => | ||
// other attributes aren't needed for the purposes of injecting metadata | ||
({ management: { icon: `${type}-icon` } } as any) | ||
); | ||
|
||
savedObjectsClient = handlerContext.savedObjects.client; | ||
savedObjectsClient.find.mockResolvedValue(emptyResponse); | ||
savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); | ||
|
||
const router = httpSetup.createRouter('/internal/saved_objects/'); | ||
registerCopyRoute(router, config); | ||
|
||
await server.start(); | ||
}); | ||
|
||
afterEach(async () => { | ||
await server.stop(); | ||
}); | ||
|
||
it('formats successful response', async () => { | ||
exportSavedObjectsToStream.mockResolvedValueOnce(createListStream([])); | ||
|
||
const result = await supertest(httpSetup.server.listener) | ||
.post(URL) | ||
.send({ | ||
objects: [ | ||
{ | ||
type: 'index-pattern', | ||
id: 'my-pattern', | ||
}, | ||
{ | ||
type: 'dashboard', | ||
id: 'my-dashboard', | ||
}, | ||
], | ||
includeReferencesDeep: true, | ||
targetWorkspace: 'test_workspace', | ||
}) | ||
.expect(200); | ||
|
||
expect(result.body).toEqual({ success: true, successCount: 0 }); | ||
expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created | ||
}); | ||
|
||
it('requires objects', async () => { | ||
const result = await supertest(httpSetup.server.listener).post(URL).send({}).expect(400); | ||
|
||
expect(result.body.message).toMatchInlineSnapshot( | ||
`"[request body.objects]: expected value of type [array] but got [undefined]"` | ||
); | ||
}); | ||
|
||
it('requires target workspace', async () => { | ||
const result = await supertest(httpSetup.server.listener) | ||
.post(URL) | ||
.send({ | ||
objects: [ | ||
{ | ||
type: 'index-pattern', | ||
id: 'my-pattern', | ||
}, | ||
{ | ||
type: 'dashboard', | ||
id: 'my-dashboard', | ||
}, | ||
], | ||
includeReferencesDeep: true, | ||
}) | ||
.expect(400); | ||
|
||
expect(result.body.message).toMatchInlineSnapshot( | ||
`"[request body.targetWorkspace]: expected value of type [string] but got [undefined]"` | ||
); | ||
}); | ||
|
||
it('copy unsupported objects', async () => { | ||
const result = await supertest(httpSetup.server.listener) | ||
.post(URL) | ||
.send({ | ||
objects: [ | ||
{ | ||
type: 'unknown', | ||
id: 'my-pattern', | ||
}, | ||
], | ||
includeReferencesDeep: true, | ||
targetWorkspace: 'test_workspace', | ||
}) | ||
.expect(400); | ||
|
||
expect(result.body.message).toMatchInlineSnapshot( | ||
`"Trying to copy object(s) with unsupported types: unknown:my-pattern"` | ||
); | ||
}); | ||
|
||
it('copy index pattern and dashboard into a workspace successfully', async () => { | ||
const targetWorkspace = 'target_workspace_id'; | ||
const savedObjects = [mockIndexPattern, mockDashboard]; | ||
exportSavedObjectsToStream.mockResolvedValueOnce(createListStream(savedObjects)); | ||
savedObjectsClient.bulkCreate.mockResolvedValueOnce({ | ||
saved_objects: savedObjects.map((obj) => ({ ...obj, workspaces: [targetWorkspace] })), | ||
}); | ||
|
||
const result = await supertest(httpSetup.server.listener) | ||
.post(URL) | ||
.send({ | ||
objects: [ | ||
{ | ||
type: 'index-pattern', | ||
id: 'my-pattern', | ||
}, | ||
{ | ||
type: 'dashboard', | ||
id: 'my-dashboard', | ||
}, | ||
], | ||
includeReferencesDeep: true, | ||
targetWorkspace, | ||
}) | ||
.expect(200); | ||
expect(result.body).toEqual({ | ||
success: true, | ||
successCount: 2, | ||
successResults: [ | ||
{ | ||
type: mockIndexPattern.type, | ||
id: mockIndexPattern.id, | ||
meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, | ||
}, | ||
{ | ||
type: mockDashboard.type, | ||
id: mockDashboard.id, | ||
meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, | ||
}, | ||
], | ||
}); | ||
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('copy a visualization with missing references', async () => { | ||
const targetWorkspace = 'target_workspace_id'; | ||
const savedObjects = [mockVisualization]; | ||
const exportDetail = { | ||
exportedCount: 2, | ||
missingRefCount: 1, | ||
missingReferences: [{ type: 'index-pattern', id: 'my-pattern' }], | ||
}; | ||
exportSavedObjectsToStream.mockResolvedValueOnce( | ||
createListStream(...savedObjects, exportDetail) | ||
); | ||
|
||
const error = SavedObjectsErrorHelpers.createGenericNotFoundError( | ||
'index-pattern', | ||
'my-pattern-*' | ||
).output.payload; | ||
savedObjectsClient.bulkGet.mockResolvedValueOnce({ | ||
saved_objects: [{ ...mockIndexPattern, error }], | ||
}); | ||
|
||
const result = await supertest(httpSetup.server.listener) | ||
.post(URL) | ||
.send({ | ||
objects: [ | ||
{ | ||
type: 'visualization', | ||
id: 'my-visualization', | ||
}, | ||
], | ||
includeReferencesDeep: true, | ||
targetWorkspace, | ||
}) | ||
.expect(200); | ||
expect(result.body).toEqual({ | ||
success: false, | ||
successCount: 0, | ||
errors: [ | ||
{ | ||
id: 'my-visualization', | ||
type: 'visualization', | ||
title: 'Test visualization', | ||
meta: { title: 'Test visualization', icon: 'visualization-icon' }, | ||
error: { | ||
type: 'missing_references', | ||
references: [{ type: 'index-pattern', id: 'my-pattern' }], | ||
}, | ||
}, | ||
], | ||
}); | ||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); | ||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( | ||
[{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], | ||
expect.any(Object) // options | ||
); | ||
expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); | ||
}); | ||
}); |