Skip to content

Commit

Permalink
Add copy saved objects API (opensearch-project#217)
Browse files Browse the repository at this point in the history
* 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
gaobinlong authored and wanglam committed Feb 22, 2024
1 parent 116b5fe commit da80915
Show file tree
Hide file tree
Showing 3 changed files with 338 additions and 0 deletions.
72 changes: 72 additions & 0 deletions src/core/server/saved_objects/routes/copy.ts
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 });
})
);
};
2 changes: 2 additions & 0 deletions src/core/server/saved_objects/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { registerExportRoute } from './export';
import { registerImportRoute } from './import';
import { registerResolveImportErrorsRoute } from './resolve_import_errors';
import { registerMigrateRoute } from './migrate';
import { registerCopyRoute } from './copy';

export function registerRoutes({
http,
Expand All @@ -71,6 +72,7 @@ export function registerRoutes({
registerExportRoute(router, config);
registerImportRoute(router, config);
registerResolveImportErrorsRoute(router, config);
registerCopyRoute(router, config);

const internalRouter = http.createRouter('/internal/saved_objects/');

Expand Down
264 changes: 264 additions & 0 deletions src/core/server/saved_objects/routes/integration_tests/copy.test.ts
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();
});
});

0 comments on commit da80915

Please sign in to comment.