From 3656f93aad739b78446959195816b0294a88b769 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Apr 2024 08:34:54 +0000 Subject: [PATCH] [Workspace][Feature]Setup workspace skeleton and implement basic CRUD API (#5075) * feature: setup workspace skeleton and implement basic CRUD API on workspace Signed-off-by: Zhou Su * feat: remove useless required plugins and logger typo Signed-off-by: SuZhou-Joe * feat: setup public side skeleton Signed-off-by: SuZhou-Joe * temp: add unit test Signed-off-by: SuZhou-Joe * feat: add function test for workspace CRUD routes Signed-off-by: SuZhou-Joe * feat: use saved objects client instead of internal repository Signed-off-by: SuZhou-Joe * feat: update CHANGELOG Signed-off-by: SuZhou-Joe * feat: exclude permission check wrapper Signed-off-by: SuZhou-Joe * feat: add integration test Signed-off-by: SuZhou-Joe * feat: add configuration Signed-off-by: SuZhou-Joe * feat: enable workspace flag when run workspace related test Signed-off-by: SuZhou-Joe * feat: optimization according to PR comments Signed-off-by: SuZhou-Joe * feat: add JSDoc for workspace client Signed-off-by: SuZhou-Joe * Update src/plugins/workspace/server/integration_tests/routes.test.ts Co-authored-by: Josh Romero Signed-off-by: SuZhou-Joe * feat: remove hard-coded delay Signed-off-by: SuZhou-Joe * feat: optimize unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: Only allow workspace CRUD APIs to modify workspace metadata. Signed-off-by: SuZhou-Joe * feat: add integration test for new changes Signed-off-by: SuZhou-Joe --------- Signed-off-by: Zhou Su Signed-off-by: SuZhou-Joe Co-authored-by: Zhou Su Co-authored-by: Josh Romero (cherry picked from commit eeb325163a65c750405714ea6674ce26607608d5) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md --- src/core/server/index.ts | 4 +- src/core/utils/constants.ts | 6 + src/core/utils/index.ts | 1 + src/plugins/workspace/common/constants.ts | 6 + src/plugins/workspace/config.ts | 12 + .../workspace/opensearch_dashboards.json | 11 + src/plugins/workspace/public/index.ts | 10 + src/plugins/workspace/public/plugin.ts | 18 ++ src/plugins/workspace/server/index.ts | 21 ++ .../server/integration_tests/routes.test.ts | 222 ++++++++++++++++++ src/plugins/workspace/server/plugin.ts | 53 +++++ src/plugins/workspace/server/routes/index.ts | 161 +++++++++++++ .../workspace/server/saved_objects/index.ts | 6 + .../server/saved_objects/workspace.ts | 44 ++++ src/plugins/workspace/server/types.ts | 119 ++++++++++ src/plugins/workspace/server/utils.test.ts | 21 ++ src/plugins/workspace/server/utils.ts | 13 + .../workspace/server/workspace_client.ts | 210 +++++++++++++++++ test/api_integration/apis/index.js | 1 + test/api_integration/apis/workspace/index.ts | 147 ++++++++++++ 20 files changed, 1084 insertions(+), 2 deletions(-) create mode 100644 src/core/utils/constants.ts create mode 100644 src/plugins/workspace/common/constants.ts create mode 100644 src/plugins/workspace/config.ts create mode 100644 src/plugins/workspace/opensearch_dashboards.json create mode 100644 src/plugins/workspace/public/index.ts create mode 100644 src/plugins/workspace/public/plugin.ts create mode 100644 src/plugins/workspace/server/index.ts create mode 100644 src/plugins/workspace/server/integration_tests/routes.test.ts create mode 100644 src/plugins/workspace/server/plugin.ts create mode 100644 src/plugins/workspace/server/routes/index.ts create mode 100644 src/plugins/workspace/server/saved_objects/index.ts create mode 100644 src/plugins/workspace/server/saved_objects/workspace.ts create mode 100644 src/plugins/workspace/server/types.ts create mode 100644 src/plugins/workspace/server/utils.test.ts create mode 100644 src/plugins/workspace/server/utils.ts create mode 100644 src/plugins/workspace/server/workspace_client.ts create mode 100644 test/api_integration/apis/workspace/index.ts diff --git a/src/core/server/index.ts b/src/core/server/index.ts index d3697bdb571d..8b101ba9cf31 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -349,8 +349,8 @@ export { MetricsServiceStart, } from './metrics'; -export { AppCategory } from '../types'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { AppCategory, WorkspaceAttribute } from '../types'; +export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; export { SavedObject, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts new file mode 100644 index 000000000000..73c2d6010846 --- /dev/null +++ b/src/core/utils/constants.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const WORKSPACE_TYPE = 'workspace'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index b3b6ce4aab02..af4f9a17ae58 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,3 +37,4 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; +export { WORKSPACE_TYPE } from './constants'; diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts new file mode 100644 index 000000000000..b6bd7b00f676 --- /dev/null +++ b/src/plugins/workspace/common/constants.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; diff --git a/src/plugins/workspace/config.ts b/src/plugins/workspace/config.ts new file mode 100644 index 000000000000..79412f5c02ee --- /dev/null +++ b/src/plugins/workspace/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json new file mode 100644 index 000000000000..40a7eb5c3f9f --- /dev/null +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -0,0 +1,11 @@ +{ + "id": "workspace", + "version": "opensearchDashboards", + "server": true, + "ui": false, + "requiredPlugins": [ + "savedObjects" + ], + "optionalPlugins": [], + "requiredBundles": [] +} diff --git a/src/plugins/workspace/public/index.ts b/src/plugins/workspace/public/index.ts new file mode 100644 index 000000000000..99161a7edbd7 --- /dev/null +++ b/src/plugins/workspace/public/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspacePlugin } from './plugin'; + +export function plugin() { + return new WorkspacePlugin(); +} diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts new file mode 100644 index 000000000000..18e84e3a6f35 --- /dev/null +++ b/src/plugins/workspace/public/plugin.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Plugin } from '../../../core/public'; + +export class WorkspacePlugin implements Plugin<{}, {}, {}> { + public async setup() { + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/workspace/server/index.ts b/src/plugins/workspace/server/index.ts new file mode 100644 index 000000000000..fe44b4d71757 --- /dev/null +++ b/src/plugins/workspace/server/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; +import { WorkspacePlugin } from './plugin'; +import { configSchema } from '../config'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new WorkspacePlugin(initializerContext); +} + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export { WorkspaceFindOptions } from './types'; diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts new file mode 100644 index 000000000000..e14aa3de16a3 --- /dev/null +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceAttribute } from 'src/core/types'; +import * as osdTestServer from '../../../../core/test_helpers/osd_server'; + +const omitId = (object: T): Omit => { + const { id, ...others } = object; + return others; +}; + +const testWorkspace: WorkspaceAttribute = { + id: 'fake_id', + name: 'test_workspace', + description: 'test_workspace_description', +}; + +describe('workspace service', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + osd: { + workspace: { + enabled: true, + }, + }, + }, + }); + opensearchServer = await startOpenSearch(); + const startOSDResp = await startOpenSearchDashboards(); + root = startOSDResp.root; + }); + afterAll(async () => { + await root.shutdown(); + await opensearchServer.stop(); + }); + describe('Workspace CRUD APIs', () => { + afterEach(async () => { + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + osdTestServer.request.delete(root, `/api/workspaces/${item.id}`).expect(200) + ) + ); + }); + it('create', async () => { + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: testWorkspace, + }) + .expect(400); + + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + expect(result.body.success).toEqual(true); + expect(typeof result.body.result.id).toBe('string'); + }); + it('get', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + expect(getResult.body.result.name).toEqual(testWorkspace.name); + }); + it('update', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + await osdTestServer.request + .put(root, `/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omitId(testWorkspace), + name: 'updated', + }, + }) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + + expect(getResult.body.success).toEqual(true); + expect(getResult.body.result.name).toEqual('updated'); + }); + it('delete', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + await osdTestServer.request + .delete(root, `/api/workspaces/${result.body.result.id}`) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + + expect(getResult.body.success).toEqual(false); + }); + it('list', async () => { + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: { + ...omitId(testWorkspace), + name: 'another test workspace', + }, + }) + .expect(200); + + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + expect(listResult.body.result.total).toEqual(2); + }); + it('unable to perform operations on workspace by calling saved objects APIs', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + /** + * Can not create workspace by saved objects API + */ + await osdTestServer.request + .post(root, `/api/saved_objects/workspace`) + .send({ + attributes: { + ...omitId(testWorkspace), + name: 'another test workspace', + }, + }) + .expect(400); + + /** + * Can not get workspace by saved objects API + */ + await osdTestServer.request + .get(root, `/api/saved_objects/workspace/${result.body.result.id}`) + .expect(404); + + /** + * Can not update workspace by saved objects API + */ + await osdTestServer.request + .put(root, `/api/saved_objects/workspace/${result.body.result.id}`) + .send({ + attributes: { + name: 'another test workspace', + }, + }) + .expect(404); + + /** + * Can not delete workspace by saved objects API + */ + await osdTestServer.request + .delete(root, `/api/saved_objects/workspace/${result.body.result.id}`) + .expect(404); + + /** + * Can not find workspace by saved objects API + */ + const findResult = await osdTestServer.request + .get(root, `/api/saved_objects/_find?type=workspace`) + .expect(200); + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + expect(findResult.body.total).toEqual(0); + expect(listResult.body.result.total).toEqual(1); + }); + }); +}); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts new file mode 100644 index 000000000000..38e8a3c18f8c --- /dev/null +++ b/src/plugins/workspace/server/plugin.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + PluginInitializerContext, + CoreSetup, + Plugin, + Logger, + CoreStart, +} from '../../../core/server'; +import { IWorkspaceClientImpl } from './types'; +import { WorkspaceClient } from './workspace_client'; +import { registerRoutes } from './routes'; + +export class WorkspacePlugin implements Plugin<{}, {}> { + private readonly logger: Logger; + private client?: IWorkspaceClientImpl; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get('plugins', 'workspace'); + } + + public async setup(core: CoreSetup) { + this.logger.debug('Setting up Workspaces service'); + + this.client = new WorkspaceClient(core); + + await this.client.setup(core); + + registerRoutes({ + http: core.http, + logger: this.logger, + client: this.client as IWorkspaceClientImpl, + }); + + return { + client: this.client, + }; + } + + public start(core: CoreStart) { + this.logger.debug('Starting Workspace service'); + this.client?.setSavedObjects(core.savedObjects); + + return { + client: this.client as IWorkspaceClientImpl, + }; + } + + public stop() {} +} diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts new file mode 100644 index 000000000000..5789aa0481fa --- /dev/null +++ b/src/plugins/workspace/server/routes/index.ts @@ -0,0 +1,161 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { CoreSetup, Logger } from '../../../../core/server'; +import { IWorkspaceClientImpl } from '../types'; + +const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +const workspaceAttributesSchema = schema.object({ + description: schema.maybe(schema.string()), + name: schema.string(), + features: schema.maybe(schema.arrayOf(schema.string())), + color: schema.maybe(schema.string()), + icon: schema.maybe(schema.string()), + defaultVISTheme: schema.maybe(schema.string()), +}); + +export function registerRoutes({ + client, + logger, + http, +}: { + client: IWorkspaceClientImpl; + logger: Logger; + http: CoreSetup['http']; +}) { + const router = http.createRouter(); + router.post( + { + path: `${WORKSPACES_API_BASE_URL}/_list`, + validate: { + body: schema.object({ + search: schema.maybe(schema.string()), + sortOrder: schema.maybe(schema.string()), + perPage: schema.number({ min: 0, defaultValue: 20 }), + page: schema.number({ min: 0, defaultValue: 1 }), + sortField: schema.maybe(schema.string()), + searchFields: schema.maybe(schema.arrayOf(schema.string())), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const result = await client.list( + { + context, + request: req, + logger, + }, + req.body + ); + if (!result.success) { + return res.ok({ body: result }); + } + return res.ok({ + body: result, + }); + }) + ); + router.get( + { + path: `${WORKSPACES_API_BASE_URL}/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + const result = await client.get( + { + context, + request: req, + logger, + }, + id + ); + + return res.ok({ + body: result, + }); + }) + ); + router.post( + { + path: `${WORKSPACES_API_BASE_URL}`, + validate: { + body: schema.object({ + attributes: workspaceAttributesSchema, + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { attributes } = req.body; + + const result = await client.create( + { + context, + request: req, + logger, + }, + attributes + ); + return res.ok({ body: result }); + }) + ); + router.put( + { + path: `${WORKSPACES_API_BASE_URL}/{id?}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + attributes: workspaceAttributesSchema, + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + const { attributes } = req.body; + + const result = await client.update( + { + context, + request: req, + logger, + }, + id, + attributes + ); + return res.ok({ body: result }); + }) + ); + router.delete( + { + path: `${WORKSPACES_API_BASE_URL}/{id?}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + + const result = await client.delete( + { + context, + request: req, + logger, + }, + id + ); + return res.ok({ body: result }); + }) + ); +} diff --git a/src/plugins/workspace/server/saved_objects/index.ts b/src/plugins/workspace/server/saved_objects/index.ts new file mode 100644 index 000000000000..51653c50681e --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { workspace } from './workspace'; diff --git a/src/plugins/workspace/server/saved_objects/workspace.ts b/src/plugins/workspace/server/saved_objects/workspace.ts new file mode 100644 index 000000000000..7ff018a31dd1 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace.ts @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsType, WORKSPACE_TYPE } from '../../../../core/server'; + +export const workspace: SavedObjectsType = { + name: WORKSPACE_TYPE, + namespaceType: 'agnostic', + /** + * Disable operation by using saved objects APIs on workspace metadata + */ + hidden: true, + /** + * workspace won't appear in management page. + */ + mappings: { + dynamic: false, + properties: { + name: { + type: 'keyword', + }, + description: { + type: 'text', + }, + features: { + type: 'keyword', + }, + color: { + type: 'keyword', + }, + icon: { + type: 'keyword', + }, + defaultVISTheme: { + type: 'keyword', + }, + reserved: { + type: 'boolean', + }, + }, + }, +}; diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts new file mode 100644 index 000000000000..0f60597a7a8a --- /dev/null +++ b/src/plugins/workspace/server/types.ts @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Logger, + OpenSearchDashboardsRequest, + RequestHandlerContext, + SavedObjectsFindResponse, + CoreSetup, + WorkspaceAttribute, + SavedObjectsServiceStart, +} from '../../../core/server'; + +export interface WorkspaceFindOptions { + page?: number; + perPage?: number; + search?: string; + searchFields?: string[]; + sortField?: string; + sortOrder?: string; +} + +export interface IRequestDetail { + request: OpenSearchDashboardsRequest; + context: RequestHandlerContext; + logger: Logger; +} + +export interface IWorkspaceClientImpl { + /** + * Setup function for workspace client, need to be called before any other methods. + * @param dep {@link CoreSetup} + * @returns a promise indicate if the setup has successed. + * @public + */ + setup(dep: CoreSetup): Promise>; + /** + * Set saved objects client that will be used inside the workspace client. + * @param savedObjects {@link SavedObjectsServiceStart} + * @returns void + * @public + */ + setSavedObjects(savedObjects: SavedObjectsServiceStart): void; + /** + * Create a workspace + * @param requestDetail {@link IRequestDetail} + * @param payload {@link WorkspaceAttribute} + * @returns a Promise with a new-created id for the workspace + * @public + */ + create( + requestDetail: IRequestDetail, + payload: Omit + ): Promise>; + /** + * List workspaces + * @param requestDetail {@link IRequestDetail} + * @param options {@link WorkspaceFindOptions} + * @returns a Promise with workspaces list + * @public + */ + list( + requestDetail: IRequestDetail, + options: WorkspaceFindOptions + ): Promise< + IResponse< + { + workspaces: WorkspaceAttribute[]; + } & Pick + > + >; + /** + * Get the detail of a given workspace id + * @param requestDetail {@link IRequestDetail} + * @param id workspace id + * @returns a Promise with the detail of {@link WorkspaceAttribute} + * @public + */ + get(requestDetail: IRequestDetail, id: string): Promise>; + /** + * Update the detail of a given workspace + * @param requestDetail {@link IRequestDetail} + * @param id workspace id + * @param payload {@link WorkspaceAttribute} + * @returns a Promise with a boolean result indicating if the update operation successed. + * @public + */ + update( + requestDetail: IRequestDetail, + id: string, + payload: Omit + ): Promise>; + /** + * Delete a given workspace + * @param requestDetail {@link IRequestDetail} + * @param id workspace id + * @returns a Promise with a boolean result indicating if the delete operation successed. + * @public + */ + delete(requestDetail: IRequestDetail, id: string): Promise>; + /** + * Destroy the workspace client, should be called after the server disposes. + * @returns a Promise with a boolean result indicating if the destroy operation successed. + * @public + */ + destroy(): Promise>; +} + +export type IResponse = + | { + result: T; + success: true; + } + | { + success: false; + error?: string; + }; diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts new file mode 100644 index 000000000000..119b8889f715 --- /dev/null +++ b/src/plugins/workspace/server/utils.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateRandomId } from './utils'; + +describe('workspace utils', () => { + it('should generate id with the specified size', () => { + expect(generateRandomId(6)).toHaveLength(6); + }); + + it('should generate random IDs', () => { + const NUM_OF_ID = 10000; + const ids = new Set(); + for (let i = 0; i < NUM_OF_ID; i++) { + ids.add(generateRandomId(6)); + } + expect(ids.size).toBe(NUM_OF_ID); + }); +}); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts new file mode 100644 index 000000000000..89bfabd52657 --- /dev/null +++ b/src/plugins/workspace/server/utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'crypto'; + +/** + * Generate URL friendly random ID + */ +export const generateRandomId = (size: number) => { + return crypto.randomBytes(size).toString('base64url').slice(0, size); +}; diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts new file mode 100644 index 000000000000..890cf9bdd8a0 --- /dev/null +++ b/src/plugins/workspace/server/workspace_client.ts @@ -0,0 +1,210 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import type { + SavedObject, + SavedObjectsClientContract, + CoreSetup, + WorkspaceAttribute, + SavedObjectsServiceStart, +} from '../../../core/server'; +import { WORKSPACE_TYPE } from '../../../core/server'; +import { IWorkspaceClientImpl, WorkspaceFindOptions, IResponse, IRequestDetail } from './types'; +import { workspace } from './saved_objects'; +import { generateRandomId } from './utils'; +import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; + +const WORKSPACE_ID_SIZE = 6; + +const DUPLICATE_WORKSPACE_NAME_ERROR = i18n.translate('workspace.duplicate.name.error', { + defaultMessage: 'workspace name has already been used, try with a different name', +}); + +export class WorkspaceClient implements IWorkspaceClientImpl { + private setupDep: CoreSetup; + private savedObjects?: SavedObjectsServiceStart; + + constructor(core: CoreSetup) { + this.setupDep = core; + } + + private getScopedClientWithoutPermission( + requestDetail: IRequestDetail + ): SavedObjectsClientContract | undefined { + return this.savedObjects?.getScopedClient(requestDetail.request, { + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + includedHiddenTypes: [WORKSPACE_TYPE], + }); + } + + private getSavedObjectClientsFromRequestDetail( + requestDetail: IRequestDetail + ): SavedObjectsClientContract { + return this.savedObjects?.getScopedClient(requestDetail.request, { + includedHiddenTypes: [WORKSPACE_TYPE], + }) as SavedObjectsClientContract; + } + private getFlattenedResultWithSavedObject( + savedObject: SavedObject + ): WorkspaceAttribute { + return { + ...savedObject.attributes, + id: savedObject.id, + }; + } + private formatError(error: Error | any): string { + return error.message || error.error || 'Error'; + } + public async setup(core: CoreSetup): Promise> { + this.setupDep.savedObjects.registerType(workspace); + return { + success: true, + result: true, + }; + } + public async create( + requestDetail: IRequestDetail, + payload: Omit + ): ReturnType { + try { + const attributes = payload; + const id = generateRandomId(WORKSPACE_ID_SIZE); + const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); + const existingWorkspaceRes = await this.getScopedClientWithoutPermission(requestDetail)?.find( + { + type: WORKSPACE_TYPE, + search: attributes.name, + searchFields: ['name'], + } + ); + if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { + throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); + } + const result = await client.create>( + WORKSPACE_TYPE, + attributes, + { + id, + } + ); + return { + success: true, + result: { + id: result.id, + }, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async list( + requestDetail: IRequestDetail, + options: WorkspaceFindOptions + ): ReturnType { + try { + const { + saved_objects: savedObjects, + ...others + } = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find( + { + ...options, + type: WORKSPACE_TYPE, + } + ); + return { + success: true, + result: { + ...others, + workspaces: savedObjects.map((item) => this.getFlattenedResultWithSavedObject(item)), + }, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async get( + requestDetail: IRequestDetail, + id: string + ): Promise> { + try { + const result = await this.getSavedObjectClientsFromRequestDetail(requestDetail).get< + WorkspaceAttribute + >(WORKSPACE_TYPE, id); + return { + success: true, + result: this.getFlattenedResultWithSavedObject(result), + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async update( + requestDetail: IRequestDetail, + id: string, + payload: Omit + ): Promise> { + const attributes = payload; + try { + const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); + const workspaceInDB: SavedObject = await client.get(WORKSPACE_TYPE, id); + if (workspaceInDB.attributes.name !== attributes.name) { + const existingWorkspaceRes = await this.getScopedClientWithoutPermission( + requestDetail + )?.find({ + type: WORKSPACE_TYPE, + search: attributes.name, + searchFields: ['name'], + fields: ['_id'], + }); + if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { + throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); + } + } + await client.update>(WORKSPACE_TYPE, id, attributes, {}); + return { + success: true, + result: true, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async delete(requestDetail: IRequestDetail, id: string): Promise> { + try { + await this.getSavedObjectClientsFromRequestDetail(requestDetail).delete(WORKSPACE_TYPE, id); + return { + success: true, + result: true, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public setSavedObjects(savedObjects: SavedObjectsServiceStart) { + this.savedObjects = savedObjects; + } + public async destroy(): Promise> { + return { + success: true, + result: true, + }; + } +} diff --git a/test/api_integration/apis/index.js b/test/api_integration/apis/index.js index 54ffe6e774a5..2d870d88251d 100644 --- a/test/api_integration/apis/index.js +++ b/test/api_integration/apis/index.js @@ -45,5 +45,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./stats')); loadTestFile(require.resolve('./ui_metric')); loadTestFile(require.resolve('./telemetry')); + loadTestFile(require.resolve('./workspace')); }); } diff --git a/test/api_integration/apis/workspace/index.ts b/test/api_integration/apis/workspace/index.ts new file mode 100644 index 000000000000..9d4919b852bf --- /dev/null +++ b/test/api_integration/apis/workspace/index.ts @@ -0,0 +1,147 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from '@osd/expect'; +import { WorkspaceAttribute } from 'opensearch-dashboards/server'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const omitId = (object: T): Omit => { + const { id, ...others } = object; + return others; +}; + +const testWorkspace: WorkspaceAttribute = { + id: 'fake_id', + name: 'test_workspace', + description: 'test_workspace_description', +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Workspace CRUD apis', () => { + afterEach(async () => { + const listResult = await supertest + .post(`/api/workspaces/_list`) + .send({ + page: 1, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + supertest + .delete(`/api/workspaces/${item.id}`) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200) + ) + ); + }); + it('create', async () => { + await supertest + .post(`/api/workspaces`) + .send({ + attributes: testWorkspace, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(400); + + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + expect(result.body.success).equal(true); + expect(result.body.result.id).to.be.a('string'); + }); + it('get', async () => { + const result = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + expect(getResult.body.result.name).equal(testWorkspace.name); + }); + it('update', async () => { + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + await supertest + .put(`/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omitId(testWorkspace), + name: 'updated', + }, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + + expect(getResult.body.success).equal(true); + expect(getResult.body.result.name).equal('updated'); + }); + it('delete', async () => { + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + await supertest + .delete(`/api/workspaces/${result.body.result.id}`) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + + expect(getResult.body.success).equal(false); + }); + it('list', async () => { + await supertest + .post(`/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + await supertest + .post(`/api/workspaces`) + .send({ + attributes: { + ...omitId(testWorkspace), + name: 'another test workspace', + }, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const listResult = await supertest + .post(`/api/workspaces/_list`) + .send({ + page: 1, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + expect(listResult.body.result.total).equal(2); + }); + }); +}