Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Workspace] Add duplicate saved objects API #6288

Merged
merged 26 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d047607
Add copy saved objects API
gaobinlong Mar 28, 2024
317c565
Modify change log
gaobinlong Mar 28, 2024
bf4b53f
Add documents for all saved objects APIs
gaobinlong Mar 29, 2024
b38d1cf
merge main
gaobinlong Mar 29, 2024
d018404
Revert the yml file change
gaobinlong Apr 1, 2024
3423606
Move the duplicate api to workspace plugin
gaobinlong Apr 1, 2024
6df94c9
Modify change log
gaobinlong Apr 1, 2024
366565b
Modify api doc
gaobinlong Apr 1, 2024
0ecfe14
Check target workspace exists or not
gaobinlong Apr 2, 2024
d25ec21
Remove unused import
gaobinlong Apr 2, 2024
263a7d4
Fix test failure
gaobinlong Apr 2, 2024
4db87bb
merge main
gaobinlong Apr 3, 2024
70723bb
Merge remote-tracking branch 'upstream/main' into copy
gaobinlong Apr 7, 2024
62fcdef
Modify change log
gaobinlong Apr 7, 2024
3c64421
Modify workspace doc
gaobinlong Apr 7, 2024
7326a5f
Add more unit tests
gaobinlong Apr 8, 2024
23d5515
Some minor change
gaobinlong Apr 9, 2024
ac7afb8
Merge remote-tracking branch 'upstream/main' into copy
gaobinlong Apr 9, 2024
6e57d03
Fix test failure
gaobinlong Apr 9, 2024
55b902d
Modify test description
gaobinlong Apr 11, 2024
42242bb
Merge remote-tracking branch 'upstream/main' into copy
gaobinlong Apr 11, 2024
f172517
Optimize test description
gaobinlong Apr 11, 2024
8c9a08d
Modify test case
gaobinlong Apr 11, 2024
1df3d95
Merge remote-tracking branch 'upstream/main' into copy
gaobinlong Apr 11, 2024
5fda5a1
Merge remote-tracking branch 'upstream/main' into copy
gaobinlong Apr 11, 2024
7172f55
Minor change
gaobinlong Apr 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Workspace] Add create workspace page ([#6179](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6179))
- [Multiple Datasource] Make sure customer always have a default datasource ([#6237](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6237))
- [Workspace] Add workspace list page ([#6182](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6182))
- [Workspace] Add copy saved objects API ([#6288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6288))


### 🐛 Bug Fixes
Expand Down Expand Up @@ -1098,4 +1099,4 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

### 🔩 Tests

- Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322))
- Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322))
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',
bandinib-amzn marked this conversation as resolved.
Show resolved Hide resolved
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;

Check warning on line 33 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L32-L33

Added lines #L32 - L33 were not covered by tests

// need to access the registry for type validation, can't use the schema for this
const supportedTypes = context.core.savedObjects.typeRegistry

Check warning on line 36 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L36

Added line #L36 was not covered by tests
.getImportableAndExportableTypes()
.map((t) => t.name);

Check warning on line 38 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L38

Added line #L38 was not covered by tests

const invalidObjects = objects.filter((obj) => !supportedTypes.includes(obj.type));

Check warning on line 40 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L40

Added line #L40 was not covered by tests
if (invalidObjects.length) {
return res.badRequest({

Check warning on line 42 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L42

Added line #L42 was not covered by tests
body: {
message: `Trying to copy object(s) with unsupported types: ${invalidObjects
.map((obj) => `${obj.type}:${obj.id}`)

Check warning on line 45 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L45

Added line #L45 was not covered by tests
.join(', ')}`,
},
});
}

const objectsListStream = await exportSavedObjectsToStream({

Check warning on line 51 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L51

Added line #L51 was not covered by tests
savedObjectsClient,
objects,
exportSizeLimit: maxImportExportSize,
includeReferencesDeep,
excludeExportDetails: true,
});

const result = await importSavedObjectsFromStream({

Check warning on line 59 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L59

Added line #L59 was not covered by tests
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 });

Check warning on line 69 in src/core/server/saved_objects/routes/copy.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/routes/copy.ts#L69

Added line #L69 was not covered by tests
})
);
};
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();
});
});