From 89e4629f1fbe49881a4adde75a8fb53aed951da3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Apr 2024 08:33:54 +0000 Subject: [PATCH] [Workspace] Add a workspace client in workspace plugin (#6094) * feat: add comment Signed-off-by: SuZhou-Joe * feat: update unit test Signed-off-by: SuZhou-Joe * feat: add CHANGELOG Signed-off-by: SuZhou-Joe * feat: optimize comment Signed-off-by: SuZhou-Joe * feat: optimize comment Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe (cherry picked from commit 4b89ad0683e208875842626ccfb46d80525644ed) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md --- src/plugins/workspace/public/plugin.test.ts | 18 +- src/plugins/workspace/public/plugin.ts | 7 +- .../workspace/public/workspace_client.mock.ts | 25 ++ .../workspace/public/workspace_client.test.ts | 212 +++++++++++++ .../workspace/public/workspace_client.ts | 294 ++++++++++++++++++ 5 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 src/plugins/workspace/public/workspace_client.mock.ts create mode 100644 src/plugins/workspace/public/workspace_client.test.ts create mode 100644 src/plugins/workspace/public/workspace_client.ts diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index e1a45ee115ab..8c869415aede 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,10 +3,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { coreMock } from '../../../core/public/mocks'; +import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; +import { chromeServiceMock, coreMock } from '../../../core/public/mocks'; import { WorkspacePlugin } from './plugin'; describe('Workspace plugin', () => { + const getSetupMock = () => ({ + ...coreMock.createSetup(), + chrome: chromeServiceMock.createSetupContract(), + }); + beforeEach(() => { + WorkspaceClientMock.mockClear(); + Object.values(workspaceClientMock).forEach((item) => item.mockClear()); + }); + it('#setup', async () => { + const setupMock = getSetupMock(); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(WorkspaceClientMock).toBeCalledTimes(1); + }); + it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { const workspacePlugin = new WorkspacePlugin(); const coreStart = coreMock.createStart(); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 3840066fcee3..6f604bcf5678 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,7 +4,8 @@ */ import type { Subscription } from 'rxjs'; -import { Plugin, CoreStart } from '../../../core/public'; +import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; +import { WorkspaceClient } from './workspace_client'; export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; @@ -18,7 +19,9 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }); } } - public async setup() { + public async setup(core: CoreSetup) { + const workspaceClient = new WorkspaceClient(core.http, core.workspaces); + await workspaceClient.init(); return {}; } diff --git a/src/plugins/workspace/public/workspace_client.mock.ts b/src/plugins/workspace/public/workspace_client.mock.ts new file mode 100644 index 000000000000..2ceeae5627d1 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const 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(), +}; + +export const WorkspaceClientMock = jest.fn(function () { + return workspaceClientMock; +}); + +jest.doMock('./workspace_client', () => ({ + WorkspaceClient: WorkspaceClientMock, +})); diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts new file mode 100644 index 000000000000..c18ed3db64e7 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -0,0 +1,212 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock, workspacesServiceMock } from '../../../core/public/mocks'; +import { WorkspaceClient } from './workspace_client'; + +const getWorkspaceClient = () => { + const httpSetupMock = httpServiceMock.createSetupContract(); + const workspaceMock = workspacesServiceMock.createSetupContract(); + return { + httpSetupMock, + workspaceMock, + workspaceClient: new WorkspaceClient(httpSetupMock, workspaceMock), + }; +}; + +describe('#WorkspaceClient', () => { + it('#init', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + await workspaceClient.init(); + expect(workspaceMock.initialized$.getValue()).toEqual(true); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#enterWorkspace', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: false, + }); + const result = await workspaceClient.enterWorkspace('foo'); + expect(result.success).toEqual(false); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + }); + const successResult = await workspaceClient.enterWorkspace('foo'); + expect(workspaceMock.currentWorkspaceId$.getValue()).toEqual('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'GET', + }); + expect(successResult.success).toEqual(true); + }); + + it('#getCurrentWorkspaceId', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + }); + await workspaceClient.enterWorkspace('foo'); + expect(workspaceClient.getCurrentWorkspaceId()).toEqual({ + success: true, + result: 'foo', + }); + }); + + it('#getCurrentWorkspace', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + }, + }); + await workspaceClient.enterWorkspace('foo'); + expect(await workspaceClient.getCurrentWorkspace()).toEqual({ + success: true, + result: { + name: 'foo', + }, + }); + }); + + it('#create', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.create({ + name: 'foo', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces', { + method: 'POST', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#delete', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.delete('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'DELETE', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#list', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.list({ + perPage: 999, + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#get', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + await workspaceClient.get('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'GET', + }); + }); + + it('#update', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [ + { + id: 'foo', + }, + ], + }, + }); + await workspaceClient.update('foo', { + name: 'foo', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'PUT', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + }), + }); + expect(workspaceMock.workspaceList$.getValue()).toEqual([ + { + id: 'foo', + }, + ]); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#update with list gives error', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + let callTimes = 0; + httpSetupMock.fetch.mockImplementation(async () => { + callTimes++; + if (callTimes > 1) { + return { + success: false, + error: 'Something went wrong', + }; + } + + return { + success: true, + }; + }); + await workspaceClient.update('foo', { + name: 'foo', + }); + expect(workspaceMock.workspaceList$.getValue()).toEqual([]); + }); +}); diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts new file mode 100644 index 000000000000..3e988f38b265 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.ts @@ -0,0 +1,294 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + HttpFetchError, + HttpFetchOptions, + HttpSetup, + WorkspaceAttribute, + WorkspacesSetup, +} from '../../../core/public'; + +const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +const join = (...uriComponents: Array) => + uriComponents + .filter((comp): comp is string => Boolean(comp)) + .map(encodeURIComponent) + .join('/'); + +type IResponse = + | { + result: T; + success: true; + } + | { + success: false; + error?: string; + }; + +interface WorkspaceFindOptions { + page?: number; + perPage?: number; + search?: string; + searchFields?: string[]; + sortField?: string; + sortOrder?: string; +} + +/** + * Workspaces is OpenSearchDashboards's visualize mechanism allowing admins to + * organize related features + * + * @public + */ +export class WorkspaceClient { + private http: HttpSetup; + private workspaces: WorkspacesSetup; + + constructor(http: HttpSetup, workspaces: WorkspacesSetup) { + this.http = http; + this.workspaces = workspaces; + } + + /** + * Initialize workspace list: + * 1. Retrieve the list of workspaces + * 2. Change the initialized flag to true + */ + public async init() { + await this.updateWorkspaceList(); + this.workspaces.initialized$.next(true); + } + + /** + * Add a non-throw-error fetch method, + * so that consumers only need to care about + * if the success is false instead of wrapping the call with a try catch + * and judge the error both in catch clause and if(!success) cluase. + */ + private safeFetch = async ( + path: string, + options: HttpFetchOptions + ): Promise> => { + try { + return await this.http.fetch>(path, options); + } catch (error: unknown) { + if (error instanceof HttpFetchError) { + return { + success: false, + error: error.body?.message || error.body?.error || error.message, + }; + } + + if (error instanceof Error) { + return { + success: false, + error: error.message, + }; + } + + return { + success: false, + error: 'Unknown error', + }; + } + }; + + /** + * Filter empty sub path and join all of the sub paths into a standard http path + * + * @param path + * @returns path + */ + private getPath(...path: Array): string { + return [WORKSPACES_API_BASE_URL, join(...path)].filter((item) => item).join('/'); + } + + /** + * Fetch latest list of workspaces and update workspaceList$ to notify subscriptions + */ + private async updateWorkspaceList(): Promise { + const result = await this.list({ + perPage: 999, + }); + + if (result?.success) { + this.workspaces.workspaceList$.next(result.result.workspaces); + } else { + this.workspaces.workspaceList$.next([]); + } + } + + /** + * This method will check if a valid workspace can be found by the given workspace id, + * If so, perform a side effect of updating the core.workspace.currentWorkspaceId$. + * + * @param id workspace id + * @returns {Promise>} result for this operation + */ + public async enterWorkspace(id: string): Promise> { + const workspaceResp = await this.get(id); + if (workspaceResp.success) { + this.workspaces.currentWorkspaceId$.next(id); + return { + success: true, + result: null, + }; + } else { + return workspaceResp; + } + } + + /** + * A bypass layer to get current workspace id + */ + public getCurrentWorkspaceId(): IResponse { + const currentWorkspaceId = this.workspaces.currentWorkspaceId$.getValue(); + if (!currentWorkspaceId) { + return { + success: false, + error: i18n.translate('workspace.error.notInWorkspace', { + defaultMessage: 'You are not in any workspace yet.', + }), + }; + } + + return { + success: true, + result: currentWorkspaceId, + }; + } + + /** + * Do a find in the latest workspace list with current workspace id + */ + public async getCurrentWorkspace(): Promise> { + const currentWorkspaceIdResp = this.getCurrentWorkspaceId(); + if (currentWorkspaceIdResp.success) { + const currentWorkspaceResp = await this.get(currentWorkspaceIdResp.result); + return currentWorkspaceResp; + } else { + return currentWorkspaceIdResp; + } + } + + /** + * Create a workspace + * + * @param attributes + * @returns {Promise>>} id of the new created workspace + */ + public async create( + attributes: Omit + ): Promise>> { + const path = this.getPath(); + + const result = await this.safeFetch(path, { + method: 'POST', + body: JSON.stringify({ + attributes, + }), + }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + /** + * Deletes a workspace by workspace id + * + * @param id + * @returns {Promise>} result for this operation + */ + public async delete(id: string): Promise> { + const result = await this.safeFetch(this.getPath(id), { method: 'DELETE' }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + /** + * Search for workspaces + * + * @param {object} [options={}] + * @property {string} options.search + * @property {string} options.searchFields - see OpenSearch Simple Query String + * Query field argument for more information + * @property {integer} [options.page=1] + * @property {integer} [options.perPage=20] + * @property {array} options.fields + * @returns A find result with workspaces matching the specified search. + */ + public list( + options?: WorkspaceFindOptions + ): Promise< + IResponse<{ + workspaces: WorkspaceAttribute[]; + total: number; + per_page: number; + page: number; + }> + > { + const path = this.getPath('_list'); + return this.safeFetch(path, { + method: 'POST', + body: JSON.stringify(options || {}), + }); + } + + /** + * Fetches a single workspace by a workspace id + * + * @param {string} id + * @returns {Promise>} The metadata of the workspace for the given id. + */ + public get(id: string): Promise> { + const path = this.getPath(id); + return this.safeFetch(path, { + method: 'GET', + }); + } + + /** + * Updates a workspace + * + * @param {string} id + * @param {object} attributes + * @returns {Promise>} result for this operation + */ + public async update( + id: string, + attributes: Partial + ): Promise> { + const path = this.getPath(id); + const body = { + attributes, + }; + + const result = await this.safeFetch(path, { + method: 'PUT', + body: JSON.stringify(body), + }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + public stop() { + this.workspaces.workspaceList$.unsubscribe(); + this.workspaces.currentWorkspaceId$.unsubscribe(); + } +}