From d0476071f8a309600f52c7dbdc8ff29dda0b69f3 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 28 Mar 2024 13:50:10 +0800 Subject: [PATCH 01/19] Add copy saved objects API Signed-off-by: gaobinlong --- CHANGELOG.md | 3 +- src/core/server/saved_objects/routes/copy.ts | 72 +++++ src/core/server/saved_objects/routes/index.ts | 2 + .../routes/integration_tests/copy.test.ts | 264 ++++++++++++++++++ 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 src/core/server/saved_objects/routes/copy.ts create mode 100644 src/core/server/saved_objects/routes/integration_tests/copy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ca581115e507..dbd152c0c096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 🐛 Bug Fixes @@ -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)) \ No newline at end of file +- Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322)) diff --git a/src/core/server/saved_objects/routes/copy.ts b/src/core/server/saved_objects/routes/copy.ts new file mode 100644 index 000000000000..95e79ffd40a1 --- /dev/null +++ b/src/core/server/saved_objects/routes/copy.ts @@ -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 }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 7149474e446c..6c70276d7387 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -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, @@ -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/'); diff --git a/src/core/server/saved_objects/routes/integration_tests/copy.test.ts b/src/core/server/saved_objects/routes/integration_tests/copy.test.ts new file mode 100644 index 000000000000..e8a9d83b30ea --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/copy.test.ts @@ -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>; + +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; + + 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(); + }); +}); From 317c56586b01182971707e919b390fd294e37b9a Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 28 Mar 2024 15:16:15 +0800 Subject: [PATCH 02/19] Modify change log Signed-off-by: gaobinlong --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd152c0c096..3898a4fee68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +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 +- [Workspace] Add copy saved objects API ([#6288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6288)) ### 🐛 Bug Fixes From bf4b53f06f944da452973a4c62f71d84e4da2d64 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Fri, 29 Mar 2024 15:15:19 +0800 Subject: [PATCH 03/19] Add documents for all saved objects APIs Signed-off-by: gaobinlong --- src/plugins/saved_objects/README.md | 653 ++++++++++++++++++++++++++++ 1 file changed, 653 insertions(+) diff --git a/src/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index 2f7d98dbb36b..eedebf8e1f15 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -175,3 +175,656 @@ The migraton version will be saved as a `migrationVersion` attribute in the save ``` For a more detailed explanation on the migration, refer to [`saved objects management`](src/core/server/saved_objects/migrations/README.md). + +## Server APIs + +### Get saved objects API + +Retrieve a single saved object by its ID. + +* Path and HTTP methods + +```json +GET :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | YES | The ID of the saved object. | + +* Example request + +```json +GET api/saved_objects/index-pattern/619cc200-ecd0-11ee-95b1-e7363f9e289d +``` + +* Example response + +```json +{ + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "type": "index-pattern", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + } +} +``` + +### Bulk get saved objects API + +Retrieve mutiple saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_bulk_get +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES | The ID of the saved object. | +| `fields` | Array | NO | The fields of the saved obejct need to be returned in the response. | + +* Example request + +```json +POST api/saved_objects/_bulk_get +[ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d" + }, + { + "type": "config", + "id": "3.0.0" + } +] +``` + +* Example response + +```json +{ + "saved_objects": [ + { + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "type": "index-pattern", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + } + }, + { + "id": "3.0.0", + "type": "config", + "namespaces": [ + "default" + ], + "updated_at": "2024-03-19T06:11:41.608Z", + "version": "WzAsMV0=", + "attributes": { + "buildNum": 9007199254740991 + }, + "references": [ + + ], + "migrationVersion": { + "config": "7.9.0" + } + } + ] +} +``` + +### Find saved objects API + +Retrieve a paginated set of saved objects by mulitple conditions. + +* Path and HTTP methods + +```json +GET :/api/saved_objects/_find +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `per_page` | Number | NO | The number of saved objects to return in each page. | +| `page` | Number | NO | The page of saved objects to return. | +| `search` | String | NO | A `simple_query_string` query DSL that used to filter the saved objects. | +| `default_search_operator` | String | NO | The default operator to use for the `simple_query_string` query. | +| `search_fields` | Array | NO | The fields to perform the `simple_query_string` parsed query against. | +| `fields` | Array | NO | The fields of the saved obejct need to be returned in the response. | +| `sort_field` | String | NO | The field used for sorting the response. | +| `has_reference` | Object | NO | Filters to objects that have a relationship with the type and ID combination. | +| `filter` | String | NO | The query string used to filter the attribute of the saved object. | +| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +GET api/saved_objects/_find?type=index-pattern&search_fields=title +``` + +* Example response + +```json +{ + "page": 1, + "per_page": 20, + "total": 2, + "saved_objects": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-28T06:57:03.008Z", + "version": "WzksMl0=", + "namespaces": [ + "default" + ], + "score": 0 + }, + { + "type": "index-pattern", + "id": "2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3", + "attributes": { + "title": "test*", + "fields": "[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-28T07:10:13.513Z", + "version": "WzEwLDJd", + "workspaces": [ + "9gt4lB" + ], + "namespaces": [ + "default" + ], + "score": 0 + } + ] +} +``` + +### Create saved objects API + +Create saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO |The ID of the saved object. | + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | +| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/index-pattern/test-pattern +{ + "attributes": { + "title": "test-pattern-*" + } +} +``` + +* Example response + +```json +{ + "type": "index-pattern", + "id": "test-pattern", + "attributes": { + "title": "test-pattern-*" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-29T05:55:09.270Z", + "version": "WzExLDJd", + "namespaces": [ + "default" + ] +} +``` + +### Bulk create saved objects API + +Bulk create saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_bulk_create +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | NO |The ID of the saved object. | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | +| `version` | String | NO | The version of the saved object. | +| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/_bulk_create +[ + { + "type": "index-pattern", + "id": "test-pattern1", + "attributes": { + "title": "test-pattern1-*" + } + } +] +``` + +* Example response + +```json +{ + "saved_objects": [ + { + "type": "index-pattern", + "id": "test-pattern1", + "attributes": { + "title": "test-pattern1-*" + }, + "references": [ + + ], + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "updated_at": "2024-03-29T06:01:59.453Z", + "version": "WzEyLDJd", + "namespaces": [ + "default" + ] + } + ] +} +``` +### Upate saved objects API + +Update saved objects. + +* Path and HTTP methods + +```json +PUT :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO |The ID of the saved object. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the saved object. | +| `references` | Array | NO | The attributes of the referenced objects. | + +* Example request + +```json +PUT api/saved_objects/index-pattern/test-pattern +{ + "attributes": { + "title": "test-pattern-update-*" + } +} +``` + +* Example response + +```json +{ + "id": "test-pattern", + "type": "index-pattern", + "updated_at": "2024-03-29T06:04:32.743Z", + "version": "WzEzLDJd", + "namespaces": [ + "default" + ], + "attributes": { + "title": "test-pattern-update-*" + } +} +``` +### Delete saved objects API + +Delete saved objects. + +* Path and HTTP methods + +```json +DELETE :/api/saved_objects// +``` + +* Path parameters + +The following table lists the available path parameters. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `` | String | NO | The ID of the saved object. | + +* Example request + +```json +DELETE api/saved_objects/index-pattern/test-pattern +``` + +* Example response + +```json +{} +``` +### Export saved object API + +Export saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_export +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String|Array | NO | The types of the saved object to be included in the export. | +| `objects` | Array | NO | A list of saved objects to export. | +| `includeReferencesDeep` | Boolean | NO | Includes all of the referenced objects in the export. | +| `excludeExportDetails` | Boolean | NO | Exclude the export summary in the export. | +| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Example request + +```json +POST api/saved_objects/_export +{ + "type": "index-pattern" +} +``` + +* Example response + +```json +{"attributes":{"fields":"[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]","title":"test*"},"id":"2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-28T07:10:13.513Z","version":"WzEwLDJd","workspaces":["9gt4lB"]} +{"attributes":{"fields":"[{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false}]","title":"test*"},"id":"619cc200-ecd0-11ee-95b1-e7363f9e289d","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-28T06:57:03.008Z","version":"WzksMl0="} +{"attributes":{"title":"test-pattern1-*"},"id":"test-pattern1","migrationVersion":{"index-pattern":"7.6.0"},"references":[],"type":"index-pattern","updated_at":"2024-03-29T06:01:59.453Z","version":"WzEyLDJd"} +{"exportedCount":3,"missingRefCount":0,"missingReferences":[]} +``` + +### Import saved object API + +Import saved objects from the file generated by the export API. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_import +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `createNewCopies` | Boolean | NO | Creates copies of the saved objects, genereate new IDs for the imported saved obejcts and resets the reference. | +| `overwrite` | Boolean | NO | Overwrites the saved objects when they already exist. | +| `dataSourceId` | String | NO | The ID of the data source. | +| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Request body + +The request body must include a multipart/form-data. + +* Example request + +```json +POST api/saved_objects/_import?createNewCopies=true --form file=@export.ndjson +``` + +* Example response + +```json +{ + "successCount": 3, + "success": true, + "successResults": [ + { + "type": "index-pattern", + "id": "2ffee5da-55b3-49b4-b9e1-c3af5d1adbd3", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "f0b08067-d6ab-4153-ba7d-0304506430d6" + }, + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "ffd3719c-2314-4022-befc-7d3007225952" + }, + { + "type": "index-pattern", + "id": "test-pattern1", + "meta": { + "title": "test-pattern1-*", + "icon": "indexPatternApp" + }, + "destinationId": "e87e7f2d-8498-4e44-8d25-f7d41f3b3844" + } + ] +} +``` + +### Resolve import saved objects errors API + +Resolve the errors if the import API returns errors, this API can be used to retry importing some saved obejcts, overwrite specific saved objects, or change the references to different saved objects. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_resolve_import_errors +``` + +* Query parameters + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `createNewCopies` | Boolean | NO | Creates copies of the saved objects, genereate new IDs for the imported saved obejcts and resets the reference. | +| `dataSourceId` | String | NO | The ID of the data source. | +| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | + +* Request body + +The request body must include a multipart/form-data. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `file` | ndjson file | YES | The same file given to the import API. | +| `retries` | Array | YES | The retry operations. | + +The attrbutes of the object in the `objects` parameter are as follows: +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES |The ID of the saved object. | +| `overwrite` | Boolean | NO | If `true`, overwrite the saved object with the same ID, defaults to `false`. | +| `destinationId` | String | NO | The destination ID that the imported object should have, if different from the current ID. | +| `replaceReferences` | Array | NO | A list of `type`, `from`, and `to` to be used to change the saved object's references. | +| `ignoreMissingReferences` | Boolean | NO | If `true`, ignores missing reference errors, defaults to `false`. | + +* Example request + +```json +POST api/saved_objects/_import?createNewCopies=true --form file=@export.ndjson --form retries='[{"type":"index-pattern","id":"my-pattern","overwrite":true}]' + +``` + +* Example response + +```json +{ + "successCount": 0, + "success": true +} +``` + +### Copy saved objects API + +Copy saved objects among workspaces. + +* Path and HTTP methods + +```json +POST :/api/saved_objects/_copy +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objects` | Array | YES | A list of saved objects to copy. | +| `targetWorkspace` | String | YES | The ID of the workspace to copy to. | +| `includeReferencesDeep` | Boolean | NO | Copy all of the referenced objects of the specified objects to the target workspace . | + +The attrbutes of the object in the `objects` parameter are as follows: +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES | The ID of the saved object. | + +* Example request + +```json +POST api/saved_objects/_copy +{ + "objects": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d" + } + ], + "targetWorkspace": "9gt4lB" +} +``` + +* Example response + +```json +{ + "successCount": 1, + "success": true, + "successResults": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "f4b724fd-9647-4bbf-bf59-610b43a62c75" + } + ] +} +``` From d018404baa462709376e2f9ca1c4b02f72399fa9 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Mon, 1 Apr 2024 15:23:21 +0800 Subject: [PATCH 04/19] Revert the yml file change Signed-off-by: gaobinlong --- config/opensearch_dashboards.yml | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index e86bf7842a90..40d643b014fd 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -1,13 +1,10 @@ # OpenSearch Dashboards is served by a back end server. This setting specifies the port to use. #server.port: 5601 -#assistant.chat.enabled: true + # Specifies the address to which the OpenSearch Dashboards server will bind. IP addresses and host names are both valid values. # The default is 'localhost', which usually means remote machines will not be able to connect. # To allow connections from remote users, set this parameter to a non-loopback address. -server.host: "0.0.0.0" - -#workspace.enabled: true -#workspace.permission.enabled: true +#server.host: "localhost" # Enables you to specify a path to mount OpenSearch Dashboards at if you are running behind a proxy. # Use the `server.rewriteBasePath` setting to tell OpenSearch Dashboards if it should remove the basePath @@ -26,7 +23,7 @@ server.host: "0.0.0.0" #server.name: "your-hostname" # The URLs of the OpenSearch instances to use for all your queries. -opensearch.hosts: ["http://localhost:9200"] +#opensearch.hosts: ["http://localhost:9200"] # OpenSearch Dashboards uses an index in OpenSearch to store saved searches, visualizations and # dashboards. OpenSearch Dashboards creates a new index if the index doesn't already exist. @@ -69,19 +66,19 @@ opensearch.hosts: ["http://localhost:9200"] # the username and password that the OpenSearch Dashboards server uses to perform maintenance on the OpenSearch Dashboards # index at startup. Your OpenSearch Dashboards users still need to authenticate with OpenSearch, which # is proxied through the OpenSearch Dashboards server. -opensearch.username: "admin" -opensearch.password: "admin" +#opensearch.username: "opensearch_dashboards_system" +#opensearch.password: "pass" # Enables SSL and paths to the PEM-format SSL certificate and SSL key files, respectively. # These settings enable SSL for outgoing requests from the OpenSearch Dashboards server to the browser. -#server.ssl.enabled: true -#server.ssl.certificate: none +#server.ssl.enabled: false #server.ssl.certificate: /path/to/your/server.crt #server.ssl.key: /path/to/your/server.key # Optional settings that provide the paths to the PEM-format SSL certificate and key files. # These files are used to verify the identity of OpenSearch Dashboards to OpenSearch and are required when # xpack.security.http.ssl.client_authentication in OpenSearch is set to required. +#opensearch.ssl.certificate: /path/to/your/client.crt #opensearch.ssl.key: /path/to/your/client.key # Optional setting that enables you to specify a path to the PEM file for the certificate @@ -89,7 +86,7 @@ opensearch.password: "admin" #opensearch.ssl.certificateAuthorities: [ "/path/to/your/CA.pem" ] # To disregard the validity of SSL certificates, change this setting's value to 'none'. -opensearch.ssl.verificationMode: none +#opensearch.ssl.verificationMode: full # Time in milliseconds to wait for OpenSearch to respond to pings. Defaults to the value of # the opensearch.requestTimeout setting. @@ -315,4 +312,4 @@ opensearch.ssl.verificationMode: none # savedObjects.permission.enabled: true # Set the value to true to enable workspace feature -workspace.enabled: true +# workspace.enabled: false From 3423606f1eab5661ca46d00f1d68fe8bd4c7b92e Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Mon, 1 Apr 2024 22:45:32 +0800 Subject: [PATCH 05/19] Move the duplicate api to workspace plugin Signed-off-by: gaobinlong --- src/core/server/saved_objects/routes/index.ts | 2 - .../routes/integration_tests/copy.test.ts | 264 ------------------ .../server/integration_tests/routes.test.ts | 134 +++++++++ src/plugins/workspace/server/plugin.ts | 3 + .../workspace/server/routes/duplicate.ts} | 21 +- src/plugins/workspace/server/routes/index.ts | 5 + 6 files changed, 154 insertions(+), 275 deletions(-) delete mode 100644 src/core/server/saved_objects/routes/integration_tests/copy.test.ts rename src/{core/server/saved_objects/routes/copy.ts => plugins/workspace/server/routes/duplicate.ts} (80%) diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 6c70276d7387..7149474e446c 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -45,7 +45,6 @@ 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, @@ -72,7 +71,6 @@ export function registerRoutes({ registerExportRoute(router, config); registerImportRoute(router, config); registerResolveImportErrorsRoute(router, config); - registerCopyRoute(router, config); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/core/server/saved_objects/routes/integration_tests/copy.test.ts b/src/core/server/saved_objects/routes/integration_tests/copy.test.ts deleted file mode 100644 index e8a9d83b30ea..000000000000 --- a/src/core/server/saved_objects/routes/integration_tests/copy.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* - * 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>; - -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; - - 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(); - }); -}); diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 061d0f3c4064..3571f0a6aed9 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -259,4 +259,138 @@ describe('workspace service', () => { expect(listResult.body.result.total).toEqual(1); }); }); + + describe('Duplicate saved objects APIs', () => { + const mockIndexPattern = { + type: 'index-pattern', + id: 'my-pattern', + attributes: { title: 'my-pattern-*' }, + references: [], + }; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + + afterEach(async () => { + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + const savedObjectsRepository = osd.coreStart.savedObjects.createInternalRepository([ + WORKSPACE_TYPE, + ]); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + // this will delete reserved workspace + savedObjectsRepository.delete(WORKSPACE_TYPE, item.id) + ) + ); + }); + + it('requires objects', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .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 osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .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('duplicate unsupported objects', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'unknown', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Trying to duplicate object(s) with unsupported types: unknown:my-pattern"` + ); + }); + + it('duplicate index pattern and dashboard into a workspace successfully', async () => { + const createWorkspaceResult: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + expect(createWorkspaceResult.body.success).toEqual(true); + expect(typeof createWorkspaceResult.body.result.id).toBe('string'); + + const targetWorkspace = createWorkspaceResult.body.result.id; + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .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' }, + }, + ], + }); + }); + }); }); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index e846470210c3..632e749ceddf 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -61,10 +61,13 @@ export class WorkspacePlugin implements Plugin { - const { maxImportExportSize } = config; +import { + IRouter, + exportSavedObjectsToStream, + importSavedObjectsFromStream, +} from '../../../../core/server'; +export const registerDuplicateRoute = ( + router: IRouter, + path: string, + maxImportExportSize: number +) => { router.post( { - path: '/_copy', + path: `${path}/_duplicate_saved_objects`, validate: { body: schema.object({ objects: schema.arrayOf( @@ -41,7 +44,7 @@ export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => if (invalidObjects.length) { return res.badRequest({ body: { - message: `Trying to copy object(s) with unsupported types: ${invalidObjects + message: `Trying to duplicate object(s) with unsupported types: ${invalidObjects .map((obj) => `${obj.type}:${obj.id}`) .join(', ')}`, }, diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 7c090be675f8..43271e95d095 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -6,6 +6,7 @@ import { schema } from '@osd/config-schema'; import { CoreSetup, Logger } from '../../../../core/server'; import { IWorkspaceClientImpl } from '../types'; +import { registerDuplicateRoute } from './duplicate'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; @@ -23,10 +24,12 @@ export function registerRoutes({ client, logger, http, + maxImportExportSize, }: { client: IWorkspaceClientImpl; logger: Logger; http: CoreSetup['http']; + maxImportExportSize: number; }) { const router = http.createRouter(); router.post( @@ -159,4 +162,6 @@ export function registerRoutes({ return res.ok({ body: result }); }) ); + + registerDuplicateRoute(router, WORKSPACES_API_BASE_URL, maxImportExportSize); } From 6df94c943507313915289eb516e5176cf287faab Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Mon, 1 Apr 2024 22:47:20 +0800 Subject: [PATCH 06/19] Modify change log Signed-off-by: gaobinlong --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f92404272f1a..b4fe4c0f47a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +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)) +- [Workspace] Add duplicate saved objects API ([#6288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6288)) - [Multiple Datasource] Add multi data source support to sample vega visualizations ([#6218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6218)) ### 🐛 Bug Fixes From 366565bed055808c13ce2c1370549d89fe5a0e20 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Mon, 1 Apr 2024 22:50:48 +0800 Subject: [PATCH 07/19] Modify api doc Signed-off-by: gaobinlong --- src/plugins/saved_objects/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index eedebf8e1f15..a4653a621cac 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -770,14 +770,14 @@ POST api/saved_objects/_import?createNewCopies=true --form file=@export.ndjson - } ``` -### Copy saved objects API +### Duplicate saved objects API -Copy saved objects among workspaces. +Duplicate saved objects among workspaces. * Path and HTTP methods ```json -POST :/api/saved_objects/_copy +POST :/api/workspaces/_duplicate_saved_objects ``` * Request body @@ -797,7 +797,7 @@ The attrbutes of the object in the `objects` parameter are as follows: * Example request ```json -POST api/saved_objects/_copy +POST api/workspaces/_duplicate_saved_objects { "objects": [ { From 0ecfe14b175007df1f9c1a00b1110d1a1015378e Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Tue, 2 Apr 2024 11:53:24 +0800 Subject: [PATCH 08/19] Check target workspace exists or not Signed-off-by: gaobinlong --- .../server/integration_tests/routes.test.ts | 26 ++++++ .../workspace/server/routes/duplicate.ts | 75 ---------------- src/plugins/workspace/server/routes/index.ts | 85 ++++++++++++++++++- 3 files changed, 109 insertions(+), 77 deletions(-) delete mode 100644 src/plugins/workspace/server/routes/duplicate.ts diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 3571f0a6aed9..9734e53b34a6 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -346,6 +346,26 @@ describe('workspace service', () => { ); }); + it('target workspace does not exist', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces/_duplicate_saved_objects`) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Get target workspace test_workspace error: Saved object [workspace/test_workspace] not found"` + ); + }); + it('duplicate index pattern and dashboard into a workspace successfully', async () => { const createWorkspaceResult: any = await osdTestServer.request .post(root, `/api/workspaces`) @@ -357,6 +377,12 @@ describe('workspace service', () => { expect(createWorkspaceResult.body.success).toEqual(true); expect(typeof createWorkspaceResult.body.result.id).toBe('string'); + const createSavedObjectsResult = await osdTestServer.request + .post(root, '/api/saved_objects/_bulk_create') + .send([mockIndexPattern, mockDashboard]) + .expect(200); + expect(createSavedObjectsResult.body.saved_objects.length).toBe(2); + const targetWorkspace = createWorkspaceResult.body.result.id; const result = await osdTestServer.request .post(root, `/api/workspaces/_duplicate_saved_objects`) diff --git a/src/plugins/workspace/server/routes/duplicate.ts b/src/plugins/workspace/server/routes/duplicate.ts deleted file mode 100644 index 4eab482f50c3..000000000000 --- a/src/plugins/workspace/server/routes/duplicate.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { schema } from '@osd/config-schema'; -import { - IRouter, - exportSavedObjectsToStream, - importSavedObjectsFromStream, -} from '../../../../core/server'; - -export const registerDuplicateRoute = ( - router: IRouter, - path: string, - maxImportExportSize: number -) => { - router.post( - { - path: `${path}/_duplicate_saved_objects`, - 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 duplicate 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 }); - }) - ); -}; diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 43271e95d095..39e056743732 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -4,7 +4,12 @@ */ import { schema } from '@osd/config-schema'; -import { CoreSetup, Logger } from '../../../../core/server'; +import { + CoreSetup, + Logger, + exportSavedObjectsToStream, + importSavedObjectsFromStream, +} from '../../../../core/server'; import { IWorkspaceClientImpl } from '../types'; import { registerDuplicateRoute } from './duplicate'; @@ -163,5 +168,81 @@ export function registerRoutes({ }) ); - registerDuplicateRoute(router, WORKSPACES_API_BASE_URL, maxImportExportSize); + // duplicate saved objects among workspaces + router.post( + { + path: `${WORKSPACES_API_BASE_URL}/_duplicate_saved_objects`, + 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 duplicate object(s) with unsupported types: ${invalidObjects + .map((obj) => `${obj.type}:${obj.id}`) + .join(', ')}`, + }, + }); + } + + // check whether the target workspace exists or not + const getTargetWorkspaceResult = await client.get( + { + context, + request: req, + logger, + }, + targetWorkspace + ); + if (!getTargetWorkspaceResult.success) { + return res.badRequest({ + body: { + message: `Get target workspace ${targetWorkspace} error: ${getTargetWorkspaceResult.error}`, + }, + }); + } + + // fetch all the details of the specified saved objects + const objectsListStream = await exportSavedObjectsToStream({ + savedObjectsClient, + objects, + exportSizeLimit: maxImportExportSize, + includeReferencesDeep, + excludeExportDetails: true, + }); + + // import the saved objects into the target workspace + 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 }); + }) + ); } From d25ec217097d7b9cc9c7f15c16f2a1ffc3575d7a Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Tue, 2 Apr 2024 12:00:28 +0800 Subject: [PATCH 09/19] Remove unused import Signed-off-by: gaobinlong --- src/plugins/workspace/server/routes/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 39e056743732..c5ad107a5af2 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -11,7 +11,6 @@ import { importSavedObjectsFromStream, } from '../../../../core/server'; import { IWorkspaceClientImpl } from '../types'; -import { registerDuplicateRoute } from './duplicate'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; From 263a7d4adc2058b88148ecac6f9a7606a5720e87 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Tue, 2 Apr 2024 13:06:13 +0800 Subject: [PATCH 10/19] Fix test failure Signed-off-by: gaobinlong --- .../server/integration_tests/routes.test.ts | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 9734e53b34a6..90de6e08b9de 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -274,7 +274,7 @@ describe('workspace service', () => { references: [], }; - afterEach(async () => { + afterAll(async () => { const listResult = await osdTestServer.request .post(root, `/api/workspaces/_list`) .send({ @@ -401,22 +401,8 @@ describe('workspace service', () => { 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(result.body.success).toEqual(true); + expect(result.body.successCount).toEqual(2); }); }); }); From 62fcdef120c072f5cbd5873c1a19b91f9b8737ac Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Sun, 7 Apr 2024 16:02:19 +0800 Subject: [PATCH 11/19] Modify change log Signed-off-by: gaobinlong --- CHANGELOG.md | 2 +- src/plugins/saved_objects/README.md | 59 --------------------------- src/plugins/workspace/README.md | 63 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 60 deletions(-) create mode 100644 src/plugins/workspace/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f12e6bb307..7179c11dccb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add update workspace page ([#6270](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6270)) - [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 duplicate saved objects API ([#6288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6288)) +- [Workspace] Add API to duplicate saved objects among workspaces ([#6288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6288)) - [Workspace] Add workspaces column to saved objects page ([#6225](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6225)) - [Multiple Datasource] Enhanced data source selector with default datasource shows as first choice ([#6293](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6293)) - [Multiple Datasource] Add multi data source support to sample vega visualizations ([#6218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6218)) diff --git a/src/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index a4653a621cac..131dfb4c3f09 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -769,62 +769,3 @@ POST api/saved_objects/_import?createNewCopies=true --form file=@export.ndjson - "success": true } ``` - -### Duplicate saved objects API - -Duplicate saved objects among workspaces. - -* Path and HTTP methods - -```json -POST :/api/workspaces/_duplicate_saved_objects -``` - -* Request body - -| Parameter | Data type | Required | Description | -| :--- | :--- | :--- | :--- | -| `objects` | Array | YES | A list of saved objects to copy. | -| `targetWorkspace` | String | YES | The ID of the workspace to copy to. | -| `includeReferencesDeep` | Boolean | NO | Copy all of the referenced objects of the specified objects to the target workspace . | - -The attrbutes of the object in the `objects` parameter are as follows: -| Parameter | Data type | Required | Description | -| :--- | :--- | :--- | :--- | -| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | -| `id` | String | YES | The ID of the saved object. | - -* Example request - -```json -POST api/workspaces/_duplicate_saved_objects -{ - "objects": [ - { - "type": "index-pattern", - "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d" - } - ], - "targetWorkspace": "9gt4lB" -} -``` - -* Example response - -```json -{ - "successCount": 1, - "success": true, - "successResults": [ - { - "type": "index-pattern", - "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", - "meta": { - "title": "test*", - "icon": "indexPatternApp" - }, - "destinationId": "f4b724fd-9647-4bbf-bf59-610b43a62c75" - } - ] -} -``` diff --git a/src/plugins/workspace/README.md b/src/plugins/workspace/README.md new file mode 100644 index 000000000000..3982d941a3a0 --- /dev/null +++ b/src/plugins/workspace/README.md @@ -0,0 +1,63 @@ +# Workspace + +## Server APIs + +### Duplicate saved objects API + +Duplicate saved objects among workspaces. + +* Path and HTTP methods + +```json +POST :/api/workspaces/_duplicate_saved_objects +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `objects` | Array | YES | A list of saved objects to copy. | +| `targetWorkspace` | String | YES | The ID of the workspace to copy to. | +| `includeReferencesDeep` | Boolean | NO | Copy all of the referenced objects of the specified objects to the target workspace . | + +The attrbutes of the object in the `objects` parameter are as follows: +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `type` | String | YES | The type of the saved object, such as `index-pattern`, `config` and `dashboard`. | +| `id` | String | YES | The ID of the saved object. | + +* Example request + +```json +POST api/workspaces/_duplicate_saved_objects +{ + "objects": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d" + } + ], + "targetWorkspace": "9gt4lB" +} +``` + +* Example response + +```json +{ + "successCount": 1, + "success": true, + "successResults": [ + { + "type": "index-pattern", + "id": "619cc200-ecd0-11ee-95b1-e7363f9e289d", + "meta": { + "title": "test*", + "icon": "indexPatternApp" + }, + "destinationId": "f4b724fd-9647-4bbf-bf59-610b43a62c75" + } + ] +} +``` + From 3c64421e8ae3cbb1eb4d18e7a39d72bcaff734b5 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Sun, 7 Apr 2024 16:36:52 +0800 Subject: [PATCH 12/19] Modify workspace doc Signed-off-by: gaobinlong --- src/plugins/workspace/README.md | 256 ++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/src/plugins/workspace/README.md b/src/plugins/workspace/README.md index 3982d941a3a0..a4834ff8b78e 100644 --- a/src/plugins/workspace/README.md +++ b/src/plugins/workspace/README.md @@ -2,6 +2,262 @@ ## Server APIs +### List workspaces API + +List workspaces. + +* Path and HTTP methods + +```json +POST :/api/workspaces/_list +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `search` | String | NO | A `simple_query_string` query DSL used to search the workspaces. | +| `searchFields` | Array | NO | The fields to perform the `simple_query_string` parsed query against. | +| `sortField` | String | NO | The fields used for sorting the response. | +| `sortOrder` | String | NO | The order used for sorting the response. | +| `perPage` | String | NO | The number of workspaces to return in each page. | +| `page` | String | NO | The page of workspaces to return. | +| `permissionModes` | Array | NO | The permission mode list. | + +* Example request + +```json +POST api/workspaces/_list +``` + +* Example response + +```json +{ + "success": true, + "result": { + "page": 1, + "per_page": 20, + "total": 3, + "workspaces": [ + { + "name": "test1", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query", + "dev_tools" + ], + "id": "hWNZls" + }, + { + "name": "test2", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query" + ], + "id": "SnkOPt" + }, + { + "name": "Global workspace", + "features": [ + "*", + "!@management" + ], + "reserved": true, + "id": "public" + } + ] + } +} +``` + + +### Get workspace API + +Retrieve a single workspace. + +* Path and HTTP methods + +```json +GET :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + +* Example request + +```json +GET api/workspaces/SnkOPt +``` + +* Example response + +```json +{ + "success": true, + "result": { + "name": "test2", + "features": [ + "workspace_update", + "workspace_overview", + "dashboards", + "visualize", + "opensearchDashboardsOverview", + "indexPatterns", + "discover", + "objects", + "objects_searches", + "objects_query" + ], + "id": "SnkOPt" + } +} +``` + +### Create workspace API + +Create a workspace. + +* Path and HTTP methods + +```json +POST :/api/workspaces +``` + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the workspace. | +| `permissions` | Object | NO | The permission info of the workspace. | + + +* Example request + +```json +POST api/workspaces +{ + "attributes": { + "name": "test4", + "description": "test4" + } +} +``` + +* Example response + +```json +{ + "success": true, + "result": { + "id": "eHVoCJ" + } +} +``` + +### Update workspace API + +Update workspace. + +* Path and HTTP methods + +```json +PUT :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + +* Request body + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `attributes` | Object | YES | The attributes of the workspace. | +| `permissions` | Object | NO | The permission info of the workspace. | + + +* Example request + +```json +PUT api/workspaces/eHVoCJ +{ + "attributes": { + "name": "test4", + "description": "test update" + } +} +``` + +* Example response + +```json +{ + "success": true, + "result": true +} +``` + +### Delete workspace API + +Delete a workspace. + +* Path and HTTP methods + +```json +DELETE :/api/workspaces/ +``` + +* Path parameters + +The following table lists the available path parameters. All path parameters are required. + +| Parameter | Data type | Required | Description | +| :--- | :--- | :--- | :--- | +| `` | String | YES | The ID of the workspace. | + + +* Example request + +```json +DELETE api/workspaces/eHVoCJ +``` + +* Example response + +```json +{ + "success": true, + "result": true +} +``` + ### Duplicate saved objects API Duplicate saved objects among workspaces. From 7326a5f8502699b325776b4abb5ab910536e95d5 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Mon, 8 Apr 2024 11:54:03 +0800 Subject: [PATCH 13/19] Add more unit tests Signed-off-by: gaobinlong --- .../integration_tests/duplicate.test.ts | 306 ++++++++++++++++++ .../workspace/server/routes/duplicate.ts | 98 ++++++ src/plugins/workspace/server/routes/index.ts | 89 +---- 3 files changed, 408 insertions(+), 85 deletions(-) create mode 100644 src/plugins/workspace/server/integration_tests/duplicate.test.ts create mode 100644 src/plugins/workspace/server/routes/duplicate.ts diff --git a/src/plugins/workspace/server/integration_tests/duplicate.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts new file mode 100644 index 000000000000..0dd8773b23be --- /dev/null +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -0,0 +1,306 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import * as exportMock from '../../../../core/server/saved_objects/export'; +import supertest from 'supertest'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { SavedObjectsErrorHelpers } from '../../../../core/server/saved_objects'; +import { UnwrapPromise } from '@osd/utility-types'; +import { loggingSystemMock, savedObjectsClientMock } from '../../../../core/server/mocks'; +import { setupServer } from '../../../../core/server/test_utils'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { workspaceClientMock } from '../../public/workspace_client.mock'; +import { registerDuplicateRoute } from '../routes/duplicate'; +import { createListStream } from '../../../../core/server/utils/streams'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { mockUuidv4 } from '../../../../core/server/saved_objects/import/__mocks__'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { createExportableType } from '../../../../core/server/saved_objects/routes/test_utils'; + +jest.mock('../../../../core/server/saved_objects/export', () => ({ + exportSavedObjectsToStream: jest.fn(), +})); + +type SetupServerReturn = UnwrapPromise>; + +const { v4: uuidv4 } = jest.requireActual('uuid'); +const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; +const URL = '/api/workspaces/_duplicate_saved_objects'; +const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; +const logger = loggingSystemMock.create(); +const clientMock = { + ...workspaceClientMock, + setup: jest.fn(), + destroy: jest.fn(), + setSavedObjects: jest.fn(), +}; + +describe(`duplicate saved objects among workspaces`, () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + 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(''); + + registerDuplicateRoute(router, logger.get(), clientMock, 10000); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + clientMock.get.mockResolvedValueOnce({ success: true }); + 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('target workspace does not exist', async () => { + clientMock.get.mockResolvedValueOnce({ success: false }); + 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: 'non-existen-workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Get target workspace non-existen-workspace error: undefined"` + ); + }); + + it('copy unsupported objects', async () => { + clientMock.get.mockResolvedValueOnce({ success: true }); + 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 duplicate 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]; + clientMock.get.mockResolvedValueOnce({ success: true }); + 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' }], + }; + clientMock.get.mockResolvedValueOnce({ success: true }); + 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(); + }); +}); diff --git a/src/plugins/workspace/server/routes/duplicate.ts b/src/plugins/workspace/server/routes/duplicate.ts new file mode 100644 index 000000000000..d142365f2ce8 --- /dev/null +++ b/src/plugins/workspace/server/routes/duplicate.ts @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { + IRouter, + Logger, + exportSavedObjectsToStream, + importSavedObjectsFromStream, +} from '../../../../core/server'; +import { WORKSPACES_API_BASE_URL } from '.'; +import { IWorkspaceClientImpl } from '../types'; + +export const registerDuplicateRoute = ( + router: IRouter, + logger: Logger, + client: IWorkspaceClientImpl, + maxImportExportSize: number +) => { + router.post( + { + path: `${WORKSPACES_API_BASE_URL}/_duplicate_saved_objects`, + 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 duplicate object(s) with unsupported types: ${invalidObjects + .map((obj) => `${obj.type}:${obj.id}`) + .join(', ')}`, + }, + }); + } + + // check whether the target workspace exists or not + const getTargetWorkspaceResult = await client.get( + { + context, + request: req, + logger, + }, + targetWorkspace + ); + if (!getTargetWorkspaceResult.success) { + return res.badRequest({ + body: { + message: `Get target workspace ${targetWorkspace} error: ${getTargetWorkspaceResult.error}`, + }, + }); + } + + // fetch all the details of the specified saved objects + const objectsListStream = await exportSavedObjectsToStream({ + savedObjectsClient, + objects, + exportSizeLimit: maxImportExportSize, + includeReferencesDeep, + excludeExportDetails: true, + }); + + // import the saved objects into the target workspace + 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 }); + }) + ); +}; diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 3e065c8f9cd0..b49bb2893575 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -4,19 +4,13 @@ */ import { schema } from '@osd/config-schema'; -import { - CoreSetup, - Logger, - exportSavedObjectsToStream, - importSavedObjectsFromStream, - PrincipalType, - ACL, -} from '../../../../core/server'; +import { CoreSetup, Logger, PrincipalType, ACL } from '../../../../core/server'; import { WorkspacePermissionMode } from '../../common/constants'; import { IWorkspaceClientImpl, WorkspaceAttributeWithPermission } from '../types'; import { SavedObjectsPermissionControlContract } from '../permission_control/client'; +import { registerDuplicateRoute } from './duplicate'; -const WORKSPACES_API_BASE_URL = '/api/workspaces'; +export const WORKSPACES_API_BASE_URL = '/api/workspaces'; const workspacePermissionMode = schema.oneOf([ schema.literal(WorkspacePermissionMode.Read), @@ -218,80 +212,5 @@ export function registerRoutes({ ); // duplicate saved objects among workspaces - router.post( - { - path: `${WORKSPACES_API_BASE_URL}/_duplicate_saved_objects`, - 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 duplicate object(s) with unsupported types: ${invalidObjects - .map((obj) => `${obj.type}:${obj.id}`) - .join(', ')}`, - }, - }); - } - - // check whether the target workspace exists or not - const getTargetWorkspaceResult = await client.get( - { - context, - request: req, - logger, - }, - targetWorkspace - ); - if (!getTargetWorkspaceResult.success) { - return res.badRequest({ - body: { - message: `Get target workspace ${targetWorkspace} error: ${getTargetWorkspaceResult.error}`, - }, - }); - } - - // fetch all the details of the specified saved objects - const objectsListStream = await exportSavedObjectsToStream({ - savedObjectsClient, - objects, - exportSizeLimit: maxImportExportSize, - includeReferencesDeep, - excludeExportDetails: true, - }); - - // import the saved objects into the target workspace - 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 }); - }) - ); + registerDuplicateRoute(router, logger, client, maxImportExportSize); } From 23d55157b49040bf1ad9de5662a68bafe6016576 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Tue, 9 Apr 2024 10:37:56 +0800 Subject: [PATCH 14/19] Some minor change Signed-off-by: gaobinlong --- src/core/server/index.ts | 1 + src/core/server/saved_objects/import/index.ts | 1 + src/plugins/saved_objects/README.md | 12 +++--- src/plugins/workspace/README.md | 2 +- .../integration_tests/duplicate.test.ts | 41 +++++++++++++------ .../workspace/server/routes/duplicate.ts | 2 +- 6 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 7cdc22c5d943..4eb57aca65c3 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -328,6 +328,7 @@ export { SavedObjectsDeleteByWorkspaceOptions, updateDataSourceNameInVegaSpec, extractVegaSpecFromSavedObject, + mockUuidv4, } from './saved_objects'; export { diff --git a/src/core/server/saved_objects/import/index.ts b/src/core/server/saved_objects/import/index.ts index f265f714cac9..fe36a81bdff7 100644 --- a/src/core/server/saved_objects/import/index.ts +++ b/src/core/server/saved_objects/import/index.ts @@ -44,3 +44,4 @@ export { SavedObjectsImportRetry, } from './types'; export { updateDataSourceNameInVegaSpec, extractVegaSpecFromSavedObject } from './utils'; +export { mockUuidv4 } from './__mocks__'; diff --git a/src/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index 131dfb4c3f09..f323b4a94609 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -331,7 +331,7 @@ GET :/api/saved_objects/_find | `sort_field` | String | NO | The field used for sorting the response. | | `has_reference` | Object | NO | Filters to objects that have a relationship with the type and ID combination. | | `filter` | String | NO | The query string used to filter the attribute of the saved object. | -| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | * Example request @@ -425,7 +425,7 @@ The following table lists the available path parameters. | :--- | :--- | :--- | :--- | | `attributes` | Object | YES | The attributes of the saved object. | | `references` | Array | NO | The attributes of the referenced objects. | -| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | * Example request @@ -486,7 +486,7 @@ POST :/api/saved_objects/_bulk_create | `attributes` | Object | YES | The attributes of the saved object. | | `references` | Array | NO | The attributes of the referenced objects. | | `version` | String | NO | The version of the saved object. | -| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | * Example request @@ -630,7 +630,7 @@ POST :/api/saved_objects/_export | `objects` | Array | NO | A list of saved objects to export. | | `includeReferencesDeep` | Boolean | NO | Includes all of the referenced objects in the export. | | `excludeExportDetails` | Boolean | NO | Exclude the export summary in the export. | -| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | * Example request @@ -667,7 +667,7 @@ POST :/api/saved_objects/_import | `createNewCopies` | Boolean | NO | Creates copies of the saved objects, genereate new IDs for the imported saved obejcts and resets the reference. | | `overwrite` | Boolean | NO | Overwrites the saved objects when they already exist. | | `dataSourceId` | String | NO | The ID of the data source. | -| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | * Request body @@ -733,7 +733,7 @@ POST :/api/saved_objects/_resolve_import_errors | :--- | :--- | :--- | :--- | | `createNewCopies` | Boolean | NO | Creates copies of the saved objects, genereate new IDs for the imported saved obejcts and resets the reference. | | `dataSourceId` | String | NO | The ID of the data source. | -| `workspaces` | String|Array | NO | The ID of the workspace which the saved objects exist in. | +| `workspaces` | String\|Array | NO | The ID of the workspace which the saved objects exist in. | * Request body diff --git a/src/plugins/workspace/README.md b/src/plugins/workspace/README.md index a4834ff8b78e..281a3f5dd195 100644 --- a/src/plugins/workspace/README.md +++ b/src/plugins/workspace/README.md @@ -274,7 +274,7 @@ POST :/api/workspaces/_duplicate_saved_objects | :--- | :--- | :--- | :--- | | `objects` | Array | YES | A list of saved objects to copy. | | `targetWorkspace` | String | YES | The ID of the workspace to copy to. | -| `includeReferencesDeep` | Boolean | NO | Copy all of the referenced objects of the specified objects to the target workspace . | +| `includeReferencesDeep` | Boolean | NO | Copy all of the referenced objects of the specified objects to the target workspace . Defaults to `true`.| The attrbutes of the object in the `objects` parameter are as follows: | Parameter | Data type | Required | Description | diff --git a/src/plugins/workspace/server/integration_tests/duplicate.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts index 0dd8773b23be..312b708ff69f 100644 --- a/src/plugins/workspace/server/integration_tests/duplicate.test.ts +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -3,22 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -// eslint-disable-next-line @osd/eslint/no-restricted-paths -import * as exportMock from '../../../../core/server/saved_objects/export'; +import * as exportMock from '../../../../core/server'; import supertest from 'supertest'; -// eslint-disable-next-line @osd/eslint/no-restricted-paths -import { SavedObjectsErrorHelpers } from '../../../../core/server/saved_objects'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; import { UnwrapPromise } from '@osd/utility-types'; import { loggingSystemMock, savedObjectsClientMock } from '../../../../core/server/mocks'; import { setupServer } from '../../../../core/server/test_utils'; -// eslint-disable-next-line @osd/eslint/no-restricted-paths -import { workspaceClientMock } from '../../public/workspace_client.mock'; import { registerDuplicateRoute } from '../routes/duplicate'; import { createListStream } from '../../../../core/server/utils/streams'; -// eslint-disable-next-line @osd/eslint/no-restricted-paths -import { mockUuidv4 } from '../../../../core/server/saved_objects/import/__mocks__'; -// eslint-disable-next-line @osd/eslint/no-restricted-paths -import { createExportableType } from '../../../../core/server/saved_objects/routes/test_utils'; jest.mock('../../../../core/server/saved_objects/export', () => ({ exportSavedObjectsToStream: jest.fn(), @@ -32,12 +24,35 @@ const URL = '/api/workspaces/_duplicate_saved_objects'; const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; const logger = loggingSystemMock.create(); const clientMock = { - ...workspaceClientMock, + init: jest.fn(), + enterWorkspace: jest.fn(), + getCurrentWorkspaceId: jest.fn(), + getCurrentWorkspace: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + stop: jest.fn(), setup: jest.fn(), destroy: jest.fn(), setSavedObjects: jest.fn(), }; +export const createExportableType = (name: string): exportMock.SavedObjectsType => { + return { + name, + hidden: false, + namespaceType: 'single', + mappings: { + properties: {}, + }, + management: { + importableAndExportable: true, + }, + }; +}; + describe(`duplicate saved objects among workspaces`, () => { let server: SetupServerReturn['server']; let httpSetup: SetupServerReturn['httpSetup']; @@ -71,8 +86,8 @@ describe(`duplicate saved objects among workspaces`, () => { }; beforeEach(async () => { - mockUuidv4.mockReset(); - mockUuidv4.mockImplementation(() => uuidv4()); + exportMock.mockUuidv4.mockReset(); + exportMock.mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) diff --git a/src/plugins/workspace/server/routes/duplicate.ts b/src/plugins/workspace/server/routes/duplicate.ts index d142365f2ce8..001f924c31bb 100644 --- a/src/plugins/workspace/server/routes/duplicate.ts +++ b/src/plugins/workspace/server/routes/duplicate.ts @@ -30,7 +30,7 @@ export const registerDuplicateRoute = ( id: schema.string(), }) ), - includeReferencesDeep: schema.boolean({ defaultValue: false }), + includeReferencesDeep: schema.boolean({ defaultValue: true }), targetWorkspace: schema.string(), }), }, From 6e57d032ff840e5f187bb1405cdfe7f1227ca8e3 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Tue, 9 Apr 2024 16:37:51 +0800 Subject: [PATCH 15/19] Fix test failure Signed-off-by: gaobinlong --- src/core/server/index.ts | 1 - src/core/server/saved_objects/import/index.ts | 1 - .../workspace/server/integration_tests/duplicate.test.ts | 3 --- 3 files changed, 5 deletions(-) diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 4eb57aca65c3..7cdc22c5d943 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -328,7 +328,6 @@ export { SavedObjectsDeleteByWorkspaceOptions, updateDataSourceNameInVegaSpec, extractVegaSpecFromSavedObject, - mockUuidv4, } from './saved_objects'; export { diff --git a/src/core/server/saved_objects/import/index.ts b/src/core/server/saved_objects/import/index.ts index fe36a81bdff7..f265f714cac9 100644 --- a/src/core/server/saved_objects/import/index.ts +++ b/src/core/server/saved_objects/import/index.ts @@ -44,4 +44,3 @@ export { SavedObjectsImportRetry, } from './types'; export { updateDataSourceNameInVegaSpec, extractVegaSpecFromSavedObject } from './utils'; -export { mockUuidv4 } from './__mocks__'; diff --git a/src/plugins/workspace/server/integration_tests/duplicate.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts index 312b708ff69f..9f489cbf2b73 100644 --- a/src/plugins/workspace/server/integration_tests/duplicate.test.ts +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -18,7 +18,6 @@ jest.mock('../../../../core/server/saved_objects/export', () => ({ type SetupServerReturn = UnwrapPromise>; -const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; const URL = '/api/workspaces/_duplicate_saved_objects'; const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; @@ -86,8 +85,6 @@ describe(`duplicate saved objects among workspaces`, () => { }; beforeEach(async () => { - exportMock.mockUuidv4.mockReset(); - exportMock.mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) From 55b902d48a9710e87acf0fe5f0a86fde3693553f Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 11 Apr 2024 10:44:09 +0800 Subject: [PATCH 16/19] Modify test description Signed-off-by: gaobinlong --- .../workspace/server/integration_tests/duplicate.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/workspace/server/integration_tests/duplicate.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts index 9f489cbf2b73..48264a104bcd 100644 --- a/src/plugins/workspace/server/integration_tests/duplicate.test.ts +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -192,7 +192,7 @@ describe(`duplicate saved objects among workspaces`, () => { ); }); - it('copy unsupported objects', async () => { + it('duplicate unsupported objects', async () => { clientMock.get.mockResolvedValueOnce({ success: true }); const result = await supertest(httpSetup.server.listener) .post(URL) @@ -213,7 +213,7 @@ describe(`duplicate saved objects among workspaces`, () => { ); }); - it('copy index pattern and dashboard into a workspace successfully', async () => { + it('duplicate index pattern and dashboard into a workspace successfully', async () => { const targetWorkspace = 'target_workspace_id'; const savedObjects = [mockIndexPattern, mockDashboard]; clientMock.get.mockResolvedValueOnce({ success: true }); @@ -258,7 +258,7 @@ describe(`duplicate saved objects among workspaces`, () => { expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); }); - it('copy a visualization with missing references', async () => { + it('duplicate a saved object failed if its references are missing', async () => { const targetWorkspace = 'target_workspace_id'; const savedObjects = [mockVisualization]; const exportDetail = { From f17251722fa058998539f5b58e3eb89aad7b9b56 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 11 Apr 2024 11:02:05 +0800 Subject: [PATCH 17/19] Optimize test description Signed-off-by: gaobinlong --- .../workspace/server/integration_tests/duplicate.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/workspace/server/integration_tests/duplicate.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts index 48264a104bcd..5e481e819296 100644 --- a/src/plugins/workspace/server/integration_tests/duplicate.test.ts +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -110,7 +110,7 @@ describe(`duplicate saved objects among workspaces`, () => { await server.stop(); }); - it('formats successful response', async () => { + it('formats successful response if all requested saved objects are not valid', async () => { clientMock.get.mockResolvedValueOnce({ success: true }); exportSavedObjectsToStream.mockResolvedValueOnce(createListStream([])); From 8c9a08d5b5220a818a1d46acf42c4cee5153f53f Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 11 Apr 2024 11:43:28 +0800 Subject: [PATCH 18/19] Modify test case Signed-off-by: gaobinlong --- .../server/integration_tests/duplicate.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/plugins/workspace/server/integration_tests/duplicate.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts index 5e481e819296..e994586c631c 100644 --- a/src/plugins/workspace/server/integration_tests/duplicate.test.ts +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -11,6 +11,7 @@ import { loggingSystemMock, savedObjectsClientMock } from '../../../../core/serv import { setupServer } from '../../../../core/server/test_utils'; import { registerDuplicateRoute } from '../routes/duplicate'; import { createListStream } from '../../../../core/server/utils/streams'; +import Boom from '@hapi/boom'; jest.mock('../../../../core/server/saved_objects/export', () => ({ exportSavedObjectsToStream: jest.fn(), @@ -110,9 +111,16 @@ describe(`duplicate saved objects among workspaces`, () => { await server.stop(); }); - it('formats successful response if all requested saved objects are not valid', async () => { + it('duplicate failed if the requested saved objects are not valid', async () => { + const savedObjects = [mockIndexPattern, mockDashboard]; clientMock.get.mockResolvedValueOnce({ success: true }); - exportSavedObjectsToStream.mockResolvedValueOnce(createListStream([])); + exportSavedObjectsToStream.mockImplementation(() => { + const err = Boom.badRequest(); + err.output.payload.attributes = { + objects: savedObjects, + }; + throw err; + }); const result = await supertest(httpSetup.server.listener) .post(URL) @@ -130,9 +138,9 @@ describe(`duplicate saved objects among workspaces`, () => { includeReferencesDeep: true, targetWorkspace: 'test_workspace', }) - .expect(200); + .expect(400); - expect(result.body).toEqual({ success: true, successCount: 0 }); + expect(result.body.error).toEqual('Bad Request'); expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created }); From 7172f55a7000b3a93e276952124b107731dbd95d Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 11 Apr 2024 22:00:45 +0800 Subject: [PATCH 19/19] Minor change Signed-off-by: gaobinlong --- src/plugins/workspace/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/workspace/README.md b/src/plugins/workspace/README.md index 281a3f5dd195..7e3fff562d82 100644 --- a/src/plugins/workspace/README.md +++ b/src/plugins/workspace/README.md @@ -179,7 +179,7 @@ POST api/workspaces ### Update workspace API -Update workspace. +Update the attributes and permissions of a workspace. * Path and HTTP methods