From c0fc1808e05e3a5d021014db1b5e3eac7e6c6085 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Fri, 12 Apr 2024 11:26:05 +0800 Subject: [PATCH] [Workspace] Add duplicate saved objects API (#6288) * Add copy saved objects API Signed-off-by: gaobinlong * Modify change log Signed-off-by: gaobinlong * Add documents for all saved objects APIs Signed-off-by: gaobinlong * Revert the yml file change Signed-off-by: gaobinlong * Move the duplicate api to workspace plugin Signed-off-by: gaobinlong * Modify change log Signed-off-by: gaobinlong * Modify api doc Signed-off-by: gaobinlong * Check target workspace exists or not Signed-off-by: gaobinlong * Remove unused import Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong * Modify change log Signed-off-by: gaobinlong * Modify workspace doc Signed-off-by: gaobinlong * Add more unit tests Signed-off-by: gaobinlong * Some minor change Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong * Modify test description Signed-off-by: gaobinlong * Optimize test description Signed-off-by: gaobinlong * Modify test case Signed-off-by: gaobinlong * Minor change Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong --- src/core/server/saved_objects/routes/index.ts | 2 - src/plugins/saved_objects/README.md | 594 ++++++++++++++++++ src/plugins/workspace/README.md | 319 ++++++++++ .../integration_tests/duplicate.test.ts} | 112 +++- .../server/integration_tests/routes.test.ts | 146 +++++ src/plugins/workspace/server/plugin.ts | 2 + .../workspace/server/routes/duplicate.ts} | 46 +- src/plugins/workspace/server/routes/index.ts | 8 +- 8 files changed, 1191 insertions(+), 38 deletions(-) create mode 100644 src/plugins/workspace/README.md rename src/{core/server/saved_objects/routes/integration_tests/copy.test.ts => plugins/workspace/server/integration_tests/duplicate.test.ts} (69%) rename src/{core/server/saved_objects/routes/copy.ts => plugins/workspace/server/routes/duplicate.ts} (62%) 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/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index 2f7d98dbb36b..f323b4a94609 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -175,3 +175,597 @@ 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 +} +``` diff --git a/src/plugins/workspace/README.md b/src/plugins/workspace/README.md new file mode 100644 index 000000000000..7e3fff562d82 --- /dev/null +++ b/src/plugins/workspace/README.md @@ -0,0 +1,319 @@ +# Workspace + +## 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 the attributes and permissions of a 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. + +* 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 . Defaults to `true`.| + +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/core/server/saved_objects/routes/integration_tests/copy.test.ts b/src/plugins/workspace/server/integration_tests/duplicate.test.ts similarity index 69% rename from src/core/server/saved_objects/routes/integration_tests/copy.test.ts rename to src/plugins/workspace/server/integration_tests/duplicate.test.ts index e8a9d83b30ea..e994586c631c 100644 --- a/src/core/server/saved_objects/routes/integration_tests/copy.test.ts +++ b/src/plugins/workspace/server/integration_tests/duplicate.test.ts @@ -3,30 +3,57 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as exportMock from '../../export'; -import { createListStream } from '../../../utils/streams'; -import { mockUuidv4 } from '../../import/__mocks__'; +import * as exportMock from '../../../../core/server'; import supertest from 'supertest'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; 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 '../..'; +import { loggingSystemMock, savedObjectsClientMock } from '../../../../core/server/mocks'; +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('../../export', () => ({ +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 config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; -const URL = '/internal/saved_objects/_copy'; +const URL = '/api/workspaces/_duplicate_saved_objects'; const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; +const logger = loggingSystemMock.create(); +const clientMock = { + 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(), +}; -describe(`POST ${URL}`, () => { +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']; let handlerContext: SetupServerReturn['handlerContext']; @@ -59,8 +86,6 @@ describe(`POST ${URL}`, () => { }; beforeEach(async () => { - mockUuidv4.mockReset(); - mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) @@ -75,8 +100,9 @@ describe(`POST ${URL}`, () => { savedObjectsClient.find.mockResolvedValue(emptyResponse); savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); - const router = httpSetup.createRouter('/internal/saved_objects/'); - registerCopyRoute(router, config); + const router = httpSetup.createRouter(''); + + registerDuplicateRoute(router, logger.get(), clientMock, 10000); await server.start(); }); @@ -85,8 +111,16 @@ describe(`POST ${URL}`, () => { await server.stop(); }); - it('formats successful response', async () => { - exportSavedObjectsToStream.mockResolvedValueOnce(createListStream([])); + it('duplicate failed if the requested saved objects are not valid', async () => { + const savedObjects = [mockIndexPattern, mockDashboard]; + clientMock.get.mockResolvedValueOnce({ success: true }); + exportSavedObjectsToStream.mockImplementation(() => { + const err = Boom.badRequest(); + err.output.payload.attributes = { + objects: savedObjects, + }; + throw err; + }); const result = await supertest(httpSetup.server.listener) .post(URL) @@ -104,9 +138,9 @@ describe(`POST ${URL}`, () => { 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 }); @@ -141,7 +175,33 @@ describe(`POST ${URL}`, () => { ); }); - it('copy unsupported objects', async () => { + 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('duplicate unsupported objects', async () => { + clientMock.get.mockResolvedValueOnce({ success: true }); const result = await supertest(httpSetup.server.listener) .post(URL) .send({ @@ -157,13 +217,14 @@ describe(`POST ${URL}`, () => { .expect(400); expect(result.body.message).toMatchInlineSnapshot( - `"Trying to copy object(s) with unsupported types: unknown:my-pattern"` + `"Trying to duplicate object(s) with unsupported types: unknown:my-pattern"` ); }); - 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 }); exportSavedObjectsToStream.mockResolvedValueOnce(createListStream(savedObjects)); savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: savedObjects.map((obj) => ({ ...obj, workspaces: [targetWorkspace] })), @@ -205,7 +266,7 @@ describe(`POST ${URL}`, () => { 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 = { @@ -213,6 +274,7 @@ describe(`POST ${URL}`, () => { missingRefCount: 1, missingReferences: [{ type: 'index-pattern', id: 'my-pattern' }], }; + clientMock.get.mockResolvedValueOnce({ success: true }); exportSavedObjectsToStream.mockResolvedValueOnce( createListStream(...savedObjects, exportDetail) ); diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 832c43c66399..972a43a17389 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -271,6 +271,152 @@ describe('workspace service api integration test', () => { expect(listResult.body.result.total).toEqual(2); }); }); + + 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: [], + }; + + afterAll(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('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`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + 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`) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body.success).toEqual(true); + expect(result.body.successCount).toEqual(2); + }); + }); }); describe('workspace service api integration test when savedObjects.permission.enabled equal true', () => { diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index bd0b32ce62a0..6c9ff5a0424a 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -93,6 +93,7 @@ export class WorkspacePlugin implements Plugin { - const { maxImportExportSize } = config; +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: '/_copy', + path: `${WORKSPACES_API_BASE_URL}/_duplicate_saved_objects`, validate: { body: schema.object({ objects: schema.arrayOf( @@ -23,7 +30,7 @@ export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => id: schema.string(), }) ), - includeReferencesDeep: schema.boolean({ defaultValue: false }), + includeReferencesDeep: schema.boolean({ defaultValue: true }), targetWorkspace: schema.string(), }), }, @@ -41,13 +48,31 @@ 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(', ')}`, }, }); } + // 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, @@ -56,6 +81,7 @@ export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => excludeExportDetails: true, }); + // import the saved objects into the target workspace const result = await importSavedObjectsFromStream({ savedObjectsClient: context.core.savedObjects.client, typeRegistry: context.core.savedObjects.typeRegistry, diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 701eb8888130..1693e4636017 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -9,8 +9,9 @@ import { WorkspaceAttributeWithPermission } from '../../../../core/types'; import { WorkspacePermissionMode } from '../../common/constants'; import { IWorkspaceClientImpl } 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), @@ -43,12 +44,14 @@ export function registerRoutes({ client, logger, http, + maxImportExportSize, permissionControlClient, isPermissionControlEnabled, }: { client: IWorkspaceClientImpl; logger: Logger; http: CoreSetup['http']; + maxImportExportSize: number; permissionControlClient?: SavedObjectsPermissionControlContract; isPermissionControlEnabled: boolean; }) { @@ -211,4 +214,7 @@ export function registerRoutes({ return res.ok({ body: result }); }) ); + + // duplicate saved objects among workspaces + registerDuplicateRoute(router, logger, client, maxImportExportSize); }