From 01ba715eba987a7da1ee062fec0b3593a7a453ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Fri, 7 Oct 2022 10:36:30 +0200 Subject: [PATCH] feat(api): add workspaces api (#1765) * feat(api): add workspaces api * fix(api): lint issues --- packages/api/jest.config.js | 8 +- .../Workspace/WorkspaceApiOperations.ts | 3 + .../Workspace/WorkspaceApiService.spec.ts | 76 +++++++++++++++++++ .../Client/Workspace/WorkspaceApiService.ts | 43 +++++++++++ .../Workspace/WorkspaceApiServiceInterface.ts | 10 +++ packages/api/src/Domain/Client/index.ts | 2 + .../WorkspaceCreationRequestParams.ts | 7 ++ packages/api/src/Domain/Request/index.ts | 1 + .../Workspace/WorkspaceCreationResponse.ts | 9 +++ .../WorkspaceCreationResponseBody.ts | 3 + packages/api/src/Domain/Response/index.ts | 2 + .../api/src/Domain/Server/Workspace/Paths.ts | 9 +++ .../Server/Workspace/WorkspaceServer.spec.ts | 31 ++++++++ .../Server/Workspace/WorkspaceServer.ts | 16 ++++ .../Workspace/WorkspaceServerInterface.ts | 6 ++ packages/api/src/Domain/Server/index.ts | 2 + packages/services/jest.config.js | 2 +- .../Application/ApplicationInterface.ts | 6 +- .../Workspace/WorkspaceClientInterface.ts | 8 ++ .../src/Domain/Workspace/WorkspaceManager.ts | 32 ++++++++ packages/services/src/Domain/index.ts | 2 + packages/snjs/lib/Application/Application.ts | 33 +++++++- 22 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 packages/api/src/Domain/Client/Workspace/WorkspaceApiOperations.ts create mode 100644 packages/api/src/Domain/Client/Workspace/WorkspaceApiService.spec.ts create mode 100644 packages/api/src/Domain/Client/Workspace/WorkspaceApiService.ts create mode 100644 packages/api/src/Domain/Client/Workspace/WorkspaceApiServiceInterface.ts create mode 100644 packages/api/src/Domain/Request/Workspace/WorkspaceCreationRequestParams.ts create mode 100644 packages/api/src/Domain/Response/Workspace/WorkspaceCreationResponse.ts create mode 100644 packages/api/src/Domain/Response/Workspace/WorkspaceCreationResponseBody.ts create mode 100644 packages/api/src/Domain/Server/Workspace/Paths.ts create mode 100644 packages/api/src/Domain/Server/Workspace/WorkspaceServer.spec.ts create mode 100644 packages/api/src/Domain/Server/Workspace/WorkspaceServer.ts create mode 100644 packages/api/src/Domain/Server/Workspace/WorkspaceServerInterface.ts create mode 100644 packages/services/src/Domain/Workspace/WorkspaceClientInterface.ts create mode 100644 packages/services/src/Domain/Workspace/WorkspaceManager.ts diff --git a/packages/api/jest.config.js b/packages/api/jest.config.js index 4faa56c6c..640e600c6 100644 --- a/packages/api/jest.config.js +++ b/packages/api/jest.config.js @@ -10,10 +10,10 @@ module.exports = { }, coverageThreshold: { global: { - branches: 20, - functions: 66, - lines: 63, - statements: 63 + branches: 22, + functions: 69, + lines: 67, + statements: 67 } } }; diff --git a/packages/api/src/Domain/Client/Workspace/WorkspaceApiOperations.ts b/packages/api/src/Domain/Client/Workspace/WorkspaceApiOperations.ts new file mode 100644 index 000000000..ac5046560 --- /dev/null +++ b/packages/api/src/Domain/Client/Workspace/WorkspaceApiOperations.ts @@ -0,0 +1,3 @@ +export enum WorkspaceApiOperations { + Creating, +} diff --git a/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.spec.ts b/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.spec.ts new file mode 100644 index 000000000..0cfeab6f3 --- /dev/null +++ b/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.spec.ts @@ -0,0 +1,76 @@ +import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse' +import { WorkspaceServerInterface } from '../../Server/Workspace/WorkspaceServerInterface' + +import { WorkspaceApiOperations } from './WorkspaceApiOperations' +import { WorkspaceApiService } from './WorkspaceApiService' + +describe('WorkspaceApiService', () => { + let workspaceServer: WorkspaceServerInterface + + const createService = () => new WorkspaceApiService(workspaceServer) + + beforeEach(() => { + workspaceServer = {} as jest.Mocked + workspaceServer.createWorkspace = jest.fn().mockReturnValue({ + data: { uuid: '1-2-3' }, + } as jest.Mocked) + }) + + it('should create a workspace', async () => { + const response = await createService().createWorkspace({ + encryptedPrivateKey: 'foo', + encryptedWorkspaceKey: 'bar', + publicKey: 'buzz', + }) + + expect(response).toEqual({ + data: { + uuid: '1-2-3', + }, + }) + expect(workspaceServer.createWorkspace).toHaveBeenCalledWith({ + encryptedPrivateKey: 'foo', + encryptedWorkspaceKey: 'bar', + publicKey: 'buzz', + }) + }) + + it('should not create a workspace if it is already creating', async () => { + const service = createService() + Object.defineProperty(service, 'operationsInProgress', { + get: () => new Map([[WorkspaceApiOperations.Creating, true]]), + }) + + let error = null + try { + await service.createWorkspace({ + encryptedPrivateKey: 'foo', + encryptedWorkspaceKey: 'bar', + publicKey: 'buzz', + }) + } catch (caughtError) { + error = caughtError + } + + expect(error).not.toBeNull() + }) + + it('should not create a workspace if the server fails', async () => { + workspaceServer.createWorkspace = jest.fn().mockImplementation(() => { + throw new Error('Oops') + }) + + let error = null + try { + await createService().createWorkspace({ + encryptedPrivateKey: 'foo', + encryptedWorkspaceKey: 'bar', + publicKey: 'buzz', + }) + } catch (caughtError) { + error = caughtError + } + + expect(error).not.toBeNull() + }) +}) diff --git a/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.ts b/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.ts new file mode 100644 index 000000000..24be4e16d --- /dev/null +++ b/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.ts @@ -0,0 +1,43 @@ +import { ErrorMessage } from '../../Error/ErrorMessage' +import { ApiCallError } from '../../Error/ApiCallError' +import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse' +import { WorkspaceServerInterface } from '../../Server/Workspace/WorkspaceServerInterface' + +import { WorkspaceApiServiceInterface } from './WorkspaceApiServiceInterface' +import { WorkspaceApiOperations } from './WorkspaceApiOperations' + +export class WorkspaceApiService implements WorkspaceApiServiceInterface { + private operationsInProgress: Map + + constructor(private workspaceServer: WorkspaceServerInterface) { + this.operationsInProgress = new Map() + } + + async createWorkspace(dto: { + encryptedWorkspaceKey: string + encryptedPrivateKey: string + publicKey: string + workspaceName?: string + }): Promise { + if (this.operationsInProgress.get(WorkspaceApiOperations.Creating)) { + throw new ApiCallError(ErrorMessage.GenericInProgress) + } + + this.operationsInProgress.set(WorkspaceApiOperations.Creating, true) + + try { + const response = await this.workspaceServer.createWorkspace({ + encryptedPrivateKey: dto.encryptedPrivateKey, + encryptedWorkspaceKey: dto.encryptedWorkspaceKey, + publicKey: dto.publicKey, + workspaceName: dto.workspaceName, + }) + + this.operationsInProgress.set(WorkspaceApiOperations.Creating, false) + + return response + } catch (error) { + throw new ApiCallError(ErrorMessage.GenericFail) + } + } +} diff --git a/packages/api/src/Domain/Client/Workspace/WorkspaceApiServiceInterface.ts b/packages/api/src/Domain/Client/Workspace/WorkspaceApiServiceInterface.ts new file mode 100644 index 000000000..3c3ce5cfc --- /dev/null +++ b/packages/api/src/Domain/Client/Workspace/WorkspaceApiServiceInterface.ts @@ -0,0 +1,10 @@ +import { WorkspaceCreationResponse } from '../../Response' + +export interface WorkspaceApiServiceInterface { + createWorkspace(dto: { + encryptedWorkspaceKey: string + encryptedPrivateKey: string + publicKey: string + workspaceName?: string + }): Promise +} diff --git a/packages/api/src/Domain/Client/index.ts b/packages/api/src/Domain/Client/index.ts index 0146d7484..4235c45c6 100644 --- a/packages/api/src/Domain/Client/index.ts +++ b/packages/api/src/Domain/Client/index.ts @@ -5,3 +5,5 @@ export * from './User/UserApiService' export * from './User/UserApiServiceInterface' export * from './WebSocket/WebSocketApiService' export * from './WebSocket/WebSocketApiServiceInterface' +export * from './Workspace/WorkspaceApiService' +export * from './Workspace/WorkspaceApiServiceInterface' diff --git a/packages/api/src/Domain/Request/Workspace/WorkspaceCreationRequestParams.ts b/packages/api/src/Domain/Request/Workspace/WorkspaceCreationRequestParams.ts new file mode 100644 index 000000000..94ed0b93a --- /dev/null +++ b/packages/api/src/Domain/Request/Workspace/WorkspaceCreationRequestParams.ts @@ -0,0 +1,7 @@ +export type WorkspaceCreationRequestParams = { + encryptedWorkspaceKey: string + encryptedPrivateKey: string + publicKey: string + workspaceName?: string + [additionalParam: string]: unknown +} diff --git a/packages/api/src/Domain/Request/index.ts b/packages/api/src/Domain/Request/index.ts index daed7d22b..db5d37593 100644 --- a/packages/api/src/Domain/Request/index.ts +++ b/packages/api/src/Domain/Request/index.ts @@ -6,3 +6,4 @@ export * from './Subscription/SubscriptionInviteListRequestParams' export * from './Subscription/SubscriptionInviteRequestParams' export * from './User/UserRegistrationRequestParams' export * from './WebSocket/WebSocketConnectionTokenRequestParams' +export * from './Workspace/WorkspaceCreationRequestParams' diff --git a/packages/api/src/Domain/Response/Workspace/WorkspaceCreationResponse.ts b/packages/api/src/Domain/Response/Workspace/WorkspaceCreationResponse.ts new file mode 100644 index 000000000..b767dcff8 --- /dev/null +++ b/packages/api/src/Domain/Response/Workspace/WorkspaceCreationResponse.ts @@ -0,0 +1,9 @@ +import { Either } from '@standardnotes/common' + +import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody' +import { HttpResponse } from '../../Http/HttpResponse' +import { WorkspaceCreationResponseBody } from './WorkspaceCreationResponseBody' + +export interface WorkspaceCreationResponse extends HttpResponse { + data: Either +} diff --git a/packages/api/src/Domain/Response/Workspace/WorkspaceCreationResponseBody.ts b/packages/api/src/Domain/Response/Workspace/WorkspaceCreationResponseBody.ts new file mode 100644 index 000000000..a939c3fa8 --- /dev/null +++ b/packages/api/src/Domain/Response/Workspace/WorkspaceCreationResponseBody.ts @@ -0,0 +1,3 @@ +export type WorkspaceCreationResponseBody = { + uuid: string +} diff --git a/packages/api/src/Domain/Response/index.ts b/packages/api/src/Domain/Response/index.ts index 6b2890979..56b4b28a7 100644 --- a/packages/api/src/Domain/Response/index.ts +++ b/packages/api/src/Domain/Response/index.ts @@ -12,3 +12,5 @@ export * from './User/UserRegistrationResponse' export * from './User/UserRegistrationResponseBody' export * from './WebSocket/WebSocketConnectionTokenResponse' export * from './WebSocket/WebSocketConnectionTokenResponseBody' +export * from './Workspace/WorkspaceCreationResponse' +export * from './Workspace/WorkspaceCreationResponseBody' diff --git a/packages/api/src/Domain/Server/Workspace/Paths.ts b/packages/api/src/Domain/Server/Workspace/Paths.ts new file mode 100644 index 000000000..906a9c12a --- /dev/null +++ b/packages/api/src/Domain/Server/Workspace/Paths.ts @@ -0,0 +1,9 @@ +const WorkspacePaths = { + createWorkspace: '/v1/workspaces', +} + +export const Paths = { + v1: { + ...WorkspacePaths, + }, +} diff --git a/packages/api/src/Domain/Server/Workspace/WorkspaceServer.spec.ts b/packages/api/src/Domain/Server/Workspace/WorkspaceServer.spec.ts new file mode 100644 index 000000000..f203aeda9 --- /dev/null +++ b/packages/api/src/Domain/Server/Workspace/WorkspaceServer.spec.ts @@ -0,0 +1,31 @@ +import { HttpServiceInterface } from '../../Http' +import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse' + +import { WorkspaceServer } from './WorkspaceServer' + +describe('WorkspaceServer', () => { + let httpService: HttpServiceInterface + + const createServer = () => new WorkspaceServer(httpService) + + beforeEach(() => { + httpService = {} as jest.Mocked + httpService.post = jest.fn().mockReturnValue({ + data: { uuid: '1-2-3' }, + } as jest.Mocked) + }) + + it('should create a workspace', async () => { + const response = await createServer().createWorkspace({ + encryptedPrivateKey: 'foo', + encryptedWorkspaceKey: 'bar', + publicKey: 'buzz', + }) + + expect(response).toEqual({ + data: { + uuid: '1-2-3', + }, + }) + }) +}) diff --git a/packages/api/src/Domain/Server/Workspace/WorkspaceServer.ts b/packages/api/src/Domain/Server/Workspace/WorkspaceServer.ts new file mode 100644 index 000000000..d46f07954 --- /dev/null +++ b/packages/api/src/Domain/Server/Workspace/WorkspaceServer.ts @@ -0,0 +1,16 @@ +import { HttpServiceInterface } from '../../Http/HttpServiceInterface' +import { WorkspaceCreationRequestParams } from '../../Request/Workspace/WorkspaceCreationRequestParams' +import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse' + +import { Paths } from './Paths' +import { WorkspaceServerInterface } from './WorkspaceServerInterface' + +export class WorkspaceServer implements WorkspaceServerInterface { + constructor(private httpService: HttpServiceInterface) {} + + async createWorkspace(params: WorkspaceCreationRequestParams): Promise { + const response = await this.httpService.post(Paths.v1.createWorkspace, params) + + return response as WorkspaceCreationResponse + } +} diff --git a/packages/api/src/Domain/Server/Workspace/WorkspaceServerInterface.ts b/packages/api/src/Domain/Server/Workspace/WorkspaceServerInterface.ts new file mode 100644 index 000000000..428eb0583 --- /dev/null +++ b/packages/api/src/Domain/Server/Workspace/WorkspaceServerInterface.ts @@ -0,0 +1,6 @@ +import { WorkspaceCreationRequestParams } from '../../Request/Workspace/WorkspaceCreationRequestParams' +import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse' + +export interface WorkspaceServerInterface { + createWorkspace(params: WorkspaceCreationRequestParams): Promise +} diff --git a/packages/api/src/Domain/Server/index.ts b/packages/api/src/Domain/Server/index.ts index 013c4d9a8..36bbc562a 100644 --- a/packages/api/src/Domain/Server/index.ts +++ b/packages/api/src/Domain/Server/index.ts @@ -4,3 +4,5 @@ export * from './User/UserServer' export * from './User/UserServerInterface' export * from './WebSocket/WebSocketServer' export * from './WebSocket/WebSocketServerInterface' +export * from './Workspace/WorkspaceServer' +export * from './Workspace/WorkspaceServerInterface' diff --git a/packages/services/jest.config.js b/packages/services/jest.config.js index 62a3c171f..2f386801f 100644 --- a/packages/services/jest.config.js +++ b/packages/services/jest.config.js @@ -12,7 +12,7 @@ module.exports = { global: { branches: 9, functions: 10, - lines: 17, + lines: 16, statements: 16 } } diff --git a/packages/services/src/Domain/Application/ApplicationInterface.ts b/packages/services/src/Domain/Application/ApplicationInterface.ts index ead36aa6d..4dd3d6e48 100644 --- a/packages/services/src/Domain/Application/ApplicationInterface.ts +++ b/packages/services/src/Domain/Application/ApplicationInterface.ts @@ -1,13 +1,15 @@ import { ApplicationIdentifier, ContentType } from '@standardnotes/common' import { BackupFile, DecryptedItemInterface, ItemStream, Platform, PrefKey, PrefValue } from '@standardnotes/models' import { FilesClientInterface } from '@standardnotes/files' -import { AlertService } from '../Alert/AlertService' +import { AlertService } from '../Alert/AlertService' import { ComponentManagerInterface } from '../Component/ComponentManagerInterface' import { ApplicationEvent } from '../Event/ApplicationEvent' import { ApplicationEventCallback } from '../Event/ApplicationEventCallback' import { FeaturesClientInterface } from '../Feature/FeaturesClientInterface' import { SubscriptionClientInterface } from '../Subscription/SubscriptionClientInterface' +import { DeviceInterface } from '../Device/DeviceInterface' +import { WorkspaceClientInterface } from '../Workspace/WorkspaceClientInterface' import { ItemsClientInterface } from '../Item/ItemsClientInterface' import { MutatorClientInterface } from '../Mutator/MutatorClientInterface' import { StorageValueModes } from '../Storage/StorageTypes' @@ -15,7 +17,6 @@ import { StorageValueModes } from '../Storage/StorageTypes' import { DeinitMode } from './DeinitMode' import { DeinitSource } from './DeinitSource' import { UserClientInterface } from './UserClientInterface' -import { DeviceInterface } from '../Device/DeviceInterface' export interface ApplicationInterface { deinit(mode: DeinitMode, source: DeinitSource): void @@ -49,6 +50,7 @@ export interface ApplicationInterface { get user(): UserClientInterface get files(): FilesClientInterface get subscriptions(): SubscriptionClientInterface + get workspaces(): WorkspaceClientInterface readonly identifier: ApplicationIdentifier readonly platform: Platform deviceInterface: DeviceInterface diff --git a/packages/services/src/Domain/Workspace/WorkspaceClientInterface.ts b/packages/services/src/Domain/Workspace/WorkspaceClientInterface.ts new file mode 100644 index 000000000..b1036912b --- /dev/null +++ b/packages/services/src/Domain/Workspace/WorkspaceClientInterface.ts @@ -0,0 +1,8 @@ +export interface WorkspaceClientInterface { + createWorkspace(dto: { + encryptedWorkspaceKey: string + encryptedPrivateKey: string + publicKey: string + workspaceName?: string + }): Promise<{ uuid: string } | null> +} diff --git a/packages/services/src/Domain/Workspace/WorkspaceManager.ts b/packages/services/src/Domain/Workspace/WorkspaceManager.ts new file mode 100644 index 000000000..460c5a7ec --- /dev/null +++ b/packages/services/src/Domain/Workspace/WorkspaceManager.ts @@ -0,0 +1,32 @@ +import { WorkspaceApiServiceInterface } from '@standardnotes/api' +import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface' +import { AbstractService } from '../Service/AbstractService' +import { WorkspaceClientInterface } from './WorkspaceClientInterface' + +export class WorkspaceManager extends AbstractService implements WorkspaceClientInterface { + constructor( + private workspaceApiService: WorkspaceApiServiceInterface, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + async createWorkspace(dto: { + encryptedWorkspaceKey: string + encryptedPrivateKey: string + publicKey: string + workspaceName?: string + }): Promise<{ uuid: string } | null> { + try { + const result = await this.workspaceApiService.createWorkspace(dto) + + if (result.data.error !== undefined) { + return null + } + + return result.data + } catch (error) { + return null + } + } +} diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index 51b9ff871..0b8a43b93 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -73,3 +73,5 @@ export * from './Sync/SyncOptions' export * from './Sync/SyncQueueStrategy' export * from './Sync/SyncServiceInterface' export * from './Sync/SyncSource' +export * from './Workspace/WorkspaceClientInterface' +export * from './Workspace/WorkspaceManager' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index ce38c1b76..c3518ef88 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -14,6 +14,10 @@ import { WebSocketApiServiceInterface, WebSocketServer, WebSocketServerInterface, + WorkspaceApiService, + WorkspaceApiServiceInterface, + WorkspaceServer, + WorkspaceServerInterface, } from '@standardnotes/api' import * as Common from '@standardnotes/common' import * as ExternalServices from '@standardnotes/services' @@ -45,6 +49,8 @@ import { FileService, SubscriptionClientInterface, SubscriptionManager, + WorkspaceClientInterface, + WorkspaceManager, } from '@standardnotes/services' import { FilesClientInterface } from '@standardnotes/files' import { ComputePrivateWorkspaceIdentifier } from '@standardnotes/encryption' @@ -110,6 +116,9 @@ export class SNApplication private declare subscriptionApiService: SubscriptionApiServiceInterface private declare subscriptionServer: SubscriptionServerInterface private declare subscriptionManager: SubscriptionClientInterface + private declare workspaceApiService: WorkspaceApiServiceInterface + private declare workspaceServer: WorkspaceServerInterface + private declare workspaceManager: WorkspaceClientInterface private declare webSocketApiService: WebSocketApiServiceInterface private declare webSocketServer: WebSocketServerInterface private sessionManager!: InternalServices.SNSessionManager @@ -211,6 +220,10 @@ export class SNApplication return this.subscriptionManager } + get workspaces(): ExternalServices.WorkspaceClientInterface { + return this.workspaceManager + } + public get files(): FilesClientInterface { return this.fileService } @@ -1047,6 +1060,9 @@ export class SNApplication this.createWebSocketServer() this.createWebSocketApiService() this.createSubscriptionManager() + this.createWorkspaceServer() + this.createWorkspaceApiService() + this.createWorkspaceManager() this.createWebSocketsService() this.createSessionManager() this.createHistoryManager() @@ -1087,9 +1103,12 @@ export class SNApplication ;(this.userServer as unknown) = undefined ;(this.subscriptionApiService as unknown) = undefined ;(this.subscriptionServer as unknown) = undefined + ;(this.subscriptionManager as unknown) = undefined + ;(this.workspaceApiService as unknown) = undefined + ;(this.workspaceServer as unknown) = undefined + ;(this.workspaceManager as unknown) = undefined ;(this.webSocketApiService as unknown) = undefined ;(this.webSocketServer as unknown) = undefined - ;(this.subscriptionManager as unknown) = undefined ;(this.sessionManager as unknown) = undefined ;(this.syncService as unknown) = undefined ;(this.challengeService as unknown) = undefined @@ -1304,6 +1323,18 @@ export class SNApplication this.subscriptionManager = new SubscriptionManager(this.subscriptionApiService, this.internalEventBus) } + private createWorkspaceServer() { + this.workspaceServer = new WorkspaceServer(this.httpService) + } + + private createWorkspaceApiService() { + this.workspaceApiService = new WorkspaceApiService(this.workspaceServer) + } + + private createWorkspaceManager() { + this.workspaceManager = new WorkspaceManager(this.workspaceApiService, this.internalEventBus) + } + private createItemManager() { this.itemManager = new InternalServices.ItemManager(this.payloadManager, this.options, this.internalEventBus) this.services.push(this.itemManager)