From cf5a34ca8326cd3f2a11941f2fc31f1d12a70ebe Mon Sep 17 00:00:00 2001 From: "Christian W. Damus" Date: Fri, 30 Sep 2022 17:09:59 -0400 Subject: [PATCH] Model Service to abstract model server delegation in plug-ins Add a ModelService to locally proxy the Model Server API for plug-in extensions to use. Refactor the current implementation of edit endpoint extensions and the transaction API for reuse in this new service. Update dependencies to resolve TypeScript language version mismatch in ESLint. Work around issue in subscription URL processing in the model server client delegate uncovered in development of Mocha tests. Resolves #46 Contributed on behalf of STMicroelectronics. Signed-off-by: Christian W. Damus --- package.json | 4 +- .../src/client/model-server-client.ts | 80 ++- .../modelserver-node/src/routes/models.ts | 158 +---- .../src/server-integration-test.spec.ts | 59 +- .../modelserver-node/src/server-module.ts | 17 +- .../src/services/edit-service.ts | 170 +++++ .../src/services/model-service.spec.ts | 598 ++++++++++++++++++ .../src/services/model-service.ts | 110 ++++ packages/modelserver-plugin-ext/src/index.ts | 1 + .../src/model-service.ts | 203 ++++++ yarn.lock | 136 ++-- 11 files changed, 1272 insertions(+), 264 deletions(-) create mode 100644 packages/modelserver-node/src/services/edit-service.ts create mode 100644 packages/modelserver-node/src/services/model-service.spec.ts create mode 100644 packages/modelserver-node/src/services/model-service.ts create mode 100644 packages/modelserver-plugin-ext/src/model-service.ts diff --git a/package.json b/package.json index 2382940..ea96604 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "@types/chai-like": "^1.1.1", "@types/mocha": "^9.1.0", "@types/sinon": "^10.0.11", - "@typescript-eslint/eslint-plugin": "^5.4.0", - "@typescript-eslint/parser": "^5.4.0", + "@typescript-eslint/eslint-plugin": "^5.38.1", + "@typescript-eslint/parser": "^5.38.1", "chai": "^4.3.6", "chai-like": "^1.1.1", "eslint": "^8.3.0", diff --git a/packages/modelserver-node/src/client/model-server-client.ts b/packages/modelserver-node/src/client/model-server-client.ts index 3e8ee4d..b3265b8 100644 --- a/packages/modelserver-node/src/client/model-server-client.ts +++ b/packages/modelserver-node/src/client/model-server-client.ts @@ -27,7 +27,7 @@ import { SubscriptionOptions, TypeGuard } from '@eclipse-emfcloud/modelserver-client'; -import { Executor, Logger, ModelServerClientApi, Transaction } from '@eclipse-emfcloud/modelserver-plugin-ext'; +import { EditTransaction, Executor, Logger, ModelServerClientApi, Transaction } from '@eclipse-emfcloud/modelserver-plugin-ext'; import axios from 'axios'; import { Operation } from 'fast-json-patch'; import { inject, injectable, named } from 'inversify'; @@ -75,27 +75,13 @@ export interface InternalModelServerClientApi extends ModelServerClientApi { /** * Protocol for the client-side context of an open transaction on a model URI. */ -export interface TransactionContext extends Executor { +export interface TransactionContext extends Executor, EditTransaction { /** - * Query whether the transaction is currently open. + * Open a nested (child) transaction. + * It commits its changes into the parent transaction that opened it. + * It rolls back all changes up the transaction stack, closing the root transaction. */ - isOpen(): boolean; - - /** - * Close the transaction, putting the compound of all commands executed within its span - * onto the stack for undo/redo. - * - * @returns the aggregate result of changes performed during the transaction - */ - close(): Promise; - - /** - * Undo all changes performed during the transaction and discard them. - * - * @param error the reason for the rollback - * @returns a failed update result - */ - rollback(error: any): Promise; + openTransaction(): Promise; } /** @@ -177,14 +163,15 @@ export class InternalModelServerClient implements InternalModelServerClientApi { protected _baseURL: string; initialize(): void | Promise { - const basePath = this.upstreamConnectionConfig.baseURL.replace(/^\/+/, ''); + const basePath = this.upstreamConnectionConfig.baseURL.replace(/^\/+/, '').replace(/\/+$/, ''); this._baseURL = `http://${this.upstreamConnectionConfig.hostname}:${this.upstreamConnectionConfig.serverPort}/${basePath}`; return this.delegate.initialize(this._baseURL, DEFAULT_FORMAT); } async openTransaction(modelURI: string): Promise { if (this.transactions.has(modelURI)) { - return Promise.reject(Error(`Transaction already open on model ${modelURI}`)); + // Open a nested transaction + return this.transactions.get(modelURI).openTransaction(); } const clientID = uuid(); @@ -216,9 +203,9 @@ export class InternalModelServerClient implements InternalModelServerClientApi { const encodedParams = Object.entries(queryParameters) .map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value.toString())) .join('&'); - return `${this._baseURL}${pathToAppend}?${encodedParams}`; + return `${this._baseURL}/${pathToAppend}?${encodedParams}`; } - return `${this._baseURL}${pathToAppend}`; + return `${this._baseURL}/${pathToAppend}`; } // @@ -383,12 +370,18 @@ class DefaultTransactionContext implements TransactionContext { // Ensure that asynchronous functions don't lose their 'this' context this.open = this.open.bind(this); this.execute = this.execute.bind(this); - this.close = this.close.bind(this); + this.commit = this.commit.bind(this); this.rollback = this.rollback.bind(this); this.uuid = CompletablePromise.newPromise(); } + // Doc inherited from `EditTransaction` interface + getModelURI(): string { + return this.modelURI; + } + + // Doc inherited from `EditTransaction` interface isOpen(): boolean { return !!this.socket && this.socket.readyState === WebSocket.OPEN; } @@ -445,6 +438,14 @@ class DefaultTransactionContext implements TransactionContext { return this.uuid; } + // Doc inherited from `EditTransaction` interface + edit(patchOrCommand: Operation | Operation[] | ModelServerCommand): Promise { + if (isModelServerCommand(patchOrCommand)) { + return this.execute(this.getModelURI(), patchOrCommand); + } + return this.applyPatch(patchOrCommand); + } + // Doc inherited from `Executor` interface async applyPatch(patch: Operation | Operation[]): Promise { if (!this.isOpen()) { @@ -606,8 +607,8 @@ class DefaultTransactionContext implements TransactionContext { } } - // Doc inherited from `TransactionContext` interface - async close(): Promise { + // Doc inherited from `EditTransaction` interface + async commit(): Promise { const updateResult = this.popNestedContext(); if (!this.isOpen()) { @@ -635,7 +636,7 @@ class DefaultTransactionContext implements TransactionContext { return triggers && (typeof triggers === 'function' || triggers.length > 0); } - // Doc inherited from `TransactionContext` interface + // Doc inherited from `EditTransaction` interface async rollback(error: any): Promise { if (this.isOpen()) { this.socket.send(JSON.stringify(this.message('roll-back', { error }))); @@ -683,6 +684,29 @@ class DefaultTransactionContext implements TransactionContext { data }; } + + // Open a child transaction + openTransaction(): Promise { + // Push a nested context onto the stack for this child transaction + this.pushNestedContext(); + + // Mostly, a child transaction just delegates to the parent, especially to + // pass edits to the upstream server. The main difference is that a commit + // just pops the nested context + const result = { + getModelURI: this.getModelURI.bind(this), + isOpen: this.isOpen.bind(this), + edit: this.edit.bind(this), + applyPatch: this.applyPatch.bind(this), + execute: this.execute.bind(this), + openTransaction: this.openTransaction.bind(this), + rollback: this.rollback.bind(this), + // This is the slight wrinkle + commit: this.popNestedContext.bind(this) + }; + + return Promise.resolve(result); + } } // A model update result indicating that the transaction was already closed or was rolled back diff --git a/packages/modelserver-node/src/routes/models.ts b/packages/modelserver-node/src/routes/models.ts index 0fd0577..6926e5e 100644 --- a/packages/modelserver-node/src/routes/models.ts +++ b/packages/modelserver-node/src/routes/models.ts @@ -14,21 +14,18 @@ import { CompoundCommand, encode, ModelServerCommand, - ModelUpdateResult, RemoveCommand, SetCommand } from '@eclipse-emfcloud/modelserver-client'; -import { Executor, Logger, RouteProvider, RouterFactory, Transaction } from '@eclipse-emfcloud/modelserver-plugin-ext'; +import { Logger, RouteProvider, RouterFactory } from '@eclipse-emfcloud/modelserver-plugin-ext'; import { Request, RequestHandler, Response } from 'express'; import { Operation } from 'fast-json-patch'; -import { ServerResponse } from 'http'; import { inject, injectable, named } from 'inversify'; -import { ExecuteMessageBody, InternalModelServerClientApi, isModelServerCommand, TransactionContext } from '../client/model-server-client'; -import { CommandProviderRegistry } from '../command-provider-registry'; +import { ExecuteMessageBody, InternalModelServerClientApi, isModelServerCommand } from '../client/model-server-client'; +import { EditService } from '../services/edit-service'; import { ValidationManager } from '../services/validation-manager'; -import { TriggerProviderRegistry } from '../trigger-provider-registry'; -import { handleError, relay, respondError, validateFormat } from './routes'; +import { handleError, relay, validateFormat } from './routes'; /** * Query parameters for the `POST` or `PUT` request on the `models` endpoint. @@ -61,11 +58,8 @@ export class ModelsRoutes implements RouteProvider { @inject(InternalModelServerClientApi) protected readonly modelServerClient: InternalModelServerClientApi; - @inject(CommandProviderRegistry) - protected readonly commandProviderRegistry: CommandProviderRegistry; - - @inject(TriggerProviderRegistry) - protected readonly triggerProviderRegistry: TriggerProviderRegistry; + @inject(EditService) + protected readonly editService: EditService; @inject(ValidationManager) protected readonly validationManager: ValidationManager; @@ -137,95 +131,16 @@ export class ModelsRoutes implements RouteProvider { return; } - return isCustomCommand(command) ? this.handleCommand(modelURI, command, res) : this.forwardEdit(modelURI, command, res); + return this.forwardEdit(modelURI, command, res); }; } - protected async handleCommand(modelURI: string, command: ModelServerCommand, res: Response>): Promise { - this.logger.debug(`Getting commands provided for ${command.type}`); - - const provided = await this.commandProviderRegistry.getCommands(modelURI, command); - this.forwardEdit(modelURI, provided, res); - } - protected forwardEdit( modelURI: string, - providedEdit: ModelServerCommand | Operation | Operation[] | Transaction, + patchOrCommand: Operation | Operation[] | ModelServerCommand, res: Response> ): void { - if (this.triggerProviderRegistry.hasProviders()) { - return this.forwardEditWithTriggers(modelURI, providedEdit, res); - } - return this.forwardEditSimple(modelURI, providedEdit, res); - } - - private forwardEditSimple( - modelURI: string, - providedEdit: ModelServerCommand | Operation | Operation[] | Transaction, - res: Response> - ): void { - if (typeof providedEdit === 'function') { - // It's a transaction function - this.modelServerClient - .openTransaction(modelURI) - .then(ctx => - providedEdit(ctx) - .then(completeTransaction(ctx, res)) - .then(this.performPatchValidation(modelURI)) - .catch(error => ctx.rollback(error).finally(() => respondError(res, error))) - ) - .catch(handleError(res)); - } else { - // It's a substitute command or JSON Patch. Just execute/apply it in the usual way - let result: Promise; - - if (isModelServerCommand(providedEdit)) { - // Command case - result = this.modelServerClient.edit(modelURI, providedEdit); - } else { - // JSON Patch case - result = this.modelServerClient.edit(modelURI, providedEdit); - } - - result.then(this.performPatchValidation(modelURI)).then(relay(res)).catch(handleError(res)); - } - } - - private forwardEditWithTriggers( - modelURI: string, - providedEdit: ModelServerCommand | Operation | Operation[] | Transaction, - res: Response> - ): void { - let result = true; - - // Perform the edit in a transaction, then gather triggers, and recurse - const triggeringTransaction = async (executor: Executor): Promise => { - if (typeof providedEdit === 'function') { - // It's a transaction function - result = await providedEdit(executor); - } else { - // It's a command or JSON Patch. Just execute/apply it in the usual way - if (isModelServerCommand(providedEdit)) { - // Command case - await executor.execute(modelURI, providedEdit); - } else { - // JSON Patch case - await executor.applyPatch(providedEdit); - } - } - - return result; - }; - - this.modelServerClient - .openTransaction(modelURI) - .then(ctx => - triggeringTransaction(ctx) - .then(completeTransaction(ctx, res)) // The transaction context performs the triggers - .then(this.performPatchValidation(modelURI)) - .catch(error => ctx.rollback(error).finally(() => respondError(res, error))) - ) - .catch(handleError(res)); + this.editService.edit(modelURI, patchOrCommand).then(relay(res)).catch(handleError(res)); } /** @@ -239,24 +154,6 @@ export class ModelsRoutes implements RouteProvider { return async (delegatedResult: AnyObject) => validator.performLiveValidation(modelURI).then(() => delegatedResult); } - - /** - * Follow up patch of a model with validation of the same. - * - * @param modelURI the model patched - * @returns a function that performs live validation on a model update result if it was successful - */ - protected performPatchValidation(modelURI: string): (validate: ModelUpdateResult) => Promise { - const validator = this.validationManager; - - return async (validate: ModelUpdateResult) => { - if (validate.success) { - return validator.performLiveValidation(modelURI).then(() => validate); - } - this.logger.debug('Not validating the failed command/patch.'); - return validate; - }; - } } function asModel(object: any): AnyObject | string | undefined { @@ -313,40 +210,3 @@ function asModelServerCommand(command: any): ModelServerCommand | undefined { return undefined; } - -function isCustomCommand(command: ModelServerCommand): boolean { - return !(SetCommand.is(command) || AddCommand.is(command) || RemoveCommand.is(command)); -} - -/** - * Complete a `transaction` and send a success or error response back to the upstream client according to whether - * the downstream transaction completed successfully or not. - * - * @param transaction the transaction context to complete - * @param upstream the upstream response stream to which to send the result of transaction completion - * @returns a function that takes a downstream response and sends an error response if it is not a success response, - * otherwise a success response - */ -function completeTransaction( - transaction: TransactionContext, - upstream: Response> -): (downstream: boolean) => Promise { - return async downstream => { - if (!downstream) { - const reason = 'Transaction failed'; - return transaction.rollback(reason).finally(() => respondError(upstream, reason)); - } else { - return transaction // - .close() - .then(relay(upstream)) - .catch(respondUpdateError(upstream)); - } - }; -} - -function respondUpdateError(upstream: ServerResponse): (reason: any) => ModelUpdateResult { - return reason => { - respondError(upstream, reason); - return { success: false }; - }; -} diff --git a/packages/modelserver-node/src/server-integration-test.spec.ts b/packages/modelserver-node/src/server-integration-test.spec.ts index 5042d49..39b8cfe 100644 --- a/packages/modelserver-node/src/server-integration-test.spec.ts +++ b/packages/modelserver-node/src/server-integration-test.spec.ts @@ -9,12 +9,18 @@ * SPDX-License-Identifier: EPL-2.0 OR MIT *******************************************************************************/ import { ModelServerClientV2, ModelServerObjectV2 } from '@eclipse-emfcloud/modelserver-client'; -import { MiddlewareProvider, RouteProvider, RouterFactory, TriggerProvider } from '@eclipse-emfcloud/modelserver-plugin-ext'; +import { + EditTransaction, + MiddlewareProvider, + RouteProvider, + RouterFactory, + TriggerProvider +} from '@eclipse-emfcloud/modelserver-plugin-ext'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import * as chai from 'chai'; import { expect } from 'chai'; import * as chaiLike from 'chai-like'; -import { IRouter, NextFunction, Request, RequestHandler, Response } from 'express'; +import { IRoute, IRouter, NextFunction, Request, RequestHandler, Response } from 'express'; import { Operation } from 'fast-json-patch'; import { Container } from 'inversify'; import * as sinon from 'sinon'; @@ -36,13 +42,13 @@ export const integrationTests = undefined; chai.use(chaiLike); -type MockMiddleware = sinon.SinonSpy<[req: Request, res: Response, next: NextFunction], any>; +export type MockMiddleware = sinon.SinonSpy<[req: Request, res: Response, next: NextFunction], any>; -interface CoffeeMachine extends ModelServerObjectV2 { +export interface CoffeeMachine extends ModelServerObjectV2 { name?: string; } -namespace CoffeeMachine { +export namespace CoffeeMachine { export const TYPE = 'http://www.eclipsesource.com/modelserver/example/coffeemodel#//Machine'; } @@ -78,7 +84,7 @@ class UpstreamServer { } /** Test fixture wrapping an Inversify-configured _Model Server_ that is started up and stopped for each test case. */ -class ServerFixture { +export class ServerFixture { static upstream = new UpstreamServer(); readonly baseUrl: string; @@ -260,8 +266,13 @@ describe('Server Integration Tests', () => { }); afterEach(async () => { - transaction.rollback('Test completed, rollback.'); - await awaitClosed(transaction); + if (transaction.isOpen()) { + transaction.rollback('Test completed, rollback.'); + await awaitClosed(transaction); + } else { + // Don't interfere with the data expected by other tests + await client.undo('SuperBrewer3000.coffee'); + } }); it('isOpen()', async () => { @@ -271,7 +282,7 @@ describe('Server Integration Tests', () => { it('transaction already open', async () => { try { const duplicate = await client.openTransaction('SuperBrewer3000.coffee'); - await duplicate.rollback('Should not have been openeded.'); + await duplicate.rollback('Should not have been opened.'); expect.fail('Should not have been able to open duplicate transaction.'); } catch (error) { // Success case @@ -294,7 +305,9 @@ describe('Server Integration Tests', () => { const update2 = await transaction.applyPatch(patch2); expect(update2).to.be.like({ success: true, patch: [patch2] }); } finally { - const aggregated = await transaction.close(); + const aggregated = await transaction.commit(); + await awaitClosed(transaction); + expect(aggregated).to.be.like({ success: true, patch: [patch1, patch2] @@ -362,7 +375,7 @@ describe('Server Integration Tests', () => { try { await transaction1.applyPatch(patch); - await transaction1.close(); + await transaction1.commit(); expect.fail('Close should have thrown.'); } catch (error) { expect(error.toString()).to.have.string('Boom!'); @@ -388,7 +401,7 @@ describe('Server Integration Tests', () => { }); }); -function isCoffeeMachine(obj: unknown): obj is CoffeeMachine { +export function isCoffeeMachine(obj: unknown): obj is CoffeeMachine { return ModelServerObjectV2.is(obj) && obj.$type === CoffeeMachine.TYPE; } @@ -400,7 +413,7 @@ function isCoffeeMachine(obj: unknown): obj is CoffeeMachine { * @param forRoute the specific route for which to provide the middleware, or omitted to provide the middleware on all routes * @returns a mock middleware for Sinon spy assertions */ -function provideMiddleware(container: Container, forRoute?: string): MockMiddleware { +export function provideMiddleware(container: Container, forRoute?: string): MockMiddleware { const result: MockMiddleware = sinon.spy((req, res, next) => next()); const middlewareProvider: MiddlewareProvider = { getMiddlewares(router: IRouter, aRoute: string) { @@ -412,16 +425,16 @@ function provideMiddleware(container: Container, forRoute?: string): MockMiddlew return result; } -type SupportedMethod = 'get' | 'put' | 'post' | 'patch' | 'delete'; -interface CustomRoute { +export type SupportedMethod = keyof Pick; +export interface CustomRoute { method: SupportedMethod; path: string; handler: RequestHandler; } -function route(method: SupportedMethod, handler: RequestHandler): CustomRoute; -function route(method: SupportedMethod, path: string, handler: RequestHandler): CustomRoute; -function route(method: SupportedMethod, path?: string | RequestHandler, handler?: RequestHandler): CustomRoute { +export function route(method: SupportedMethod, handler: RequestHandler): CustomRoute; +export function route(method: SupportedMethod, path: string, handler: RequestHandler): CustomRoute; +export function route(method: SupportedMethod, path?: string | RequestHandler, handler?: RequestHandler): CustomRoute { if (typeof path === 'function') { handler = path; path = '/'; @@ -435,7 +448,7 @@ function route(method: SupportedMethod, path?: string | RequestHandler, handler? return { method, path, handler }; } -function provideEndpoint(container: Container, basePath: string, ...routes: CustomRoute[]): void { +export function provideEndpoint(container: Container, basePath: string, ...routes: CustomRoute[]): void { const provider: RouteProvider = { configureRoutes(routerFactory: RouterFactory) { const router = routerFactory(basePath); @@ -445,11 +458,11 @@ function provideEndpoint(container: Container, basePath: string, ...routes: Cust container.bind(RouteProvider).toConstantValue(provider); } -interface SocketTimeout { +export interface SocketTimeout { clear(): void; } -function socketTimeout(ws: WebSocket, reject: (reason?: any) => void): SocketTimeout { +export function socketTimeout(ws: WebSocket, reject: (reason?: any) => void): SocketTimeout { const result = setTimeout(() => { ws.close(); reject(new Error('timeout')); @@ -463,7 +476,7 @@ function socketTimeout(ws: WebSocket, reject: (reason?: any) => void): SocketTim }; } -function captureMessage(ws: WebSocket, filter?: (msg: WebSocket.MessageEvent) => boolean): Promise { +export function captureMessage(ws: WebSocket, filter?: (msg: WebSocket.MessageEvent) => boolean): Promise { let timeout: SocketTimeout; return new Promise((resolve, reject) => { @@ -479,7 +492,7 @@ function captureMessage(ws: WebSocket, filter?: (msg: WebSocket.MessageEvent) => }); } -function awaitClosed(transaction: TransactionContext): Promise { +export function awaitClosed(transaction: EditTransaction): Promise { let timeout: SocketTimeout; return new Promise(resolve => { diff --git a/packages/modelserver-node/src/server-module.ts b/packages/modelserver-node/src/server-module.ts index cc21bdc..0df8a84 100644 --- a/packages/modelserver-node/src/server-module.ts +++ b/packages/modelserver-node/src/server-module.ts @@ -9,13 +9,20 @@ * SPDX-License-Identifier: EPL-2.0 OR MIT *******************************************************************************/ -import { ModelServerClientApi, ModelServerPluginContext } from '@eclipse-emfcloud/modelserver-plugin-ext'; +import { + ModelServerClientApi, + ModelServerPluginContext, + ModelService, + ModelServiceFactory +} from '@eclipse-emfcloud/modelserver-plugin-ext'; import { ContainerModule } from 'inversify'; import { InternalModelServerClient, InternalModelServerClientApi } from './client/model-server-client'; import { CommandProviderRegistry } from './command-provider-registry'; import { BasicModelServerPluginContext, InternalModelServerPluginContext } from './plugin-context'; import { ModelServer } from './server'; +import { EditService } from './services/edit-service'; +import { DefaultModelService, MODEL_URI } from './services/model-service'; import { SubscriptionManager } from './services/subscription-manager'; import { ValidationManager } from './services/validation-manager'; import { TriggerProviderRegistry } from './trigger-provider-registry'; @@ -31,6 +38,14 @@ export default new ContainerModule(bind => { bind(SubscriptionManager).toSelf().inSingletonScope(); bind(ValidationManager).toSelf().inSingletonScope(); + bind(EditService).toSelf().inSingletonScope(); + bind(DefaultModelService).toSelf(); + bind(ModelService).toService(DefaultModelService); + bind(ModelServiceFactory).toFactory(context => (modeluri: string) => { + const child = context.container.createChild(); + child.bind(MODEL_URI).toConstantValue(modeluri); + return child.get(ModelService); + }); bind(BasicModelServerPluginContext).toSelf().inSingletonScope(); bind(ModelServerPluginContext).toService(InternalModelServerPluginContext); diff --git a/packages/modelserver-node/src/services/edit-service.ts b/packages/modelserver-node/src/services/edit-service.ts new file mode 100644 index 0000000..d93b727 --- /dev/null +++ b/packages/modelserver-node/src/services/edit-service.ts @@ -0,0 +1,170 @@ +/******************************************************************************** + * Copyright (c) 2022 STMicroelectronics. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0, or the MIT License which is + * available at https://opensource.org/licenses/MIT. + * + * SPDX-License-Identifier: EPL-2.0 OR MIT + *******************************************************************************/ +import { AddCommand, ModelServerCommand, ModelUpdateResult, RemoveCommand, SetCommand } from '@eclipse-emfcloud/modelserver-client'; +import { Executor, Logger, Transaction } from '@eclipse-emfcloud/modelserver-plugin-ext'; +import { Operation } from 'fast-json-patch'; +import { inject, injectable, named } from 'inversify'; + +import { InternalModelServerClientApi, isModelServerCommand, TransactionContext } from '../client/model-server-client'; +import { CommandProviderRegistry } from '../command-provider-registry'; +import { ValidationManager } from '../services/validation-manager'; +import { TriggerProviderRegistry } from '../trigger-provider-registry'; + +/** + * The core implementation of editing via JSON Patch or Command. + */ +@injectable() +export class EditService { + @inject(Logger) + @named(EditService.name) + protected readonly logger: Logger; + + @inject(InternalModelServerClientApi) + protected readonly modelServerClient: InternalModelServerClientApi; + + @inject(CommandProviderRegistry) + protected readonly commandProviderRegistry: CommandProviderRegistry; + + @inject(TriggerProviderRegistry) + protected readonly triggerProviderRegistry: TriggerProviderRegistry; + + @inject(ValidationManager) + protected readonly validationManager: ValidationManager; + + async edit(modelURI: string, patchOrCommand: Operation | Operation[] | ModelServerCommand): Promise { + if (isModelServerCommand(patchOrCommand)) { + // Case of executing a command + const command = patchOrCommand; + return isCustomCommand(command) ? this.handleCommand(modelURI, command) : this.forwardEdit(modelURI, command); + } + + // Case of applying a patch + const patch = patchOrCommand; + return this.forwardEdit(modelURI, patch); + } + + protected async handleCommand(modelURI: string, command: ModelServerCommand): Promise { + this.logger.debug(`Getting commands provided for ${command.type}`); + + const provided = await this.commandProviderRegistry.getCommands(modelURI, command); + return this.forwardEdit(modelURI, provided); + } + + protected forwardEdit( + modelURI: string, + providedEdit: ModelServerCommand | Operation | Operation[] | Transaction + ): Promise { + if (this.triggerProviderRegistry.hasProviders()) { + return this.forwardEditWithTriggers(modelURI, providedEdit); + } + return this.forwardEditSimple(modelURI, providedEdit); + } + + private async forwardEditSimple( + modelURI: string, + providedEdit: ModelServerCommand | Operation | Operation[] | Transaction + ): Promise { + if (typeof providedEdit === 'function') { + // It's a transaction function + return this.modelServerClient.openTransaction(modelURI).then(ctx => + providedEdit(ctx) + .then(completeTransaction(ctx)) + .then(this.performPatchValidation(modelURI)) + .catch(error => ctx.rollback(error)) + ); + } else { + // It's a substitute command or JSON Patch. Just execute/apply it in the usual way + let result: Promise; + + if (isModelServerCommand(providedEdit)) { + // Command case + result = this.modelServerClient.edit(modelURI, providedEdit); + } else { + // JSON Patch case + result = this.modelServerClient.edit(modelURI, providedEdit); + } + + return result.then(this.performPatchValidation(modelURI)); + } + } + + private async forwardEditWithTriggers( + modelURI: string, + providedEdit: ModelServerCommand | Operation | Operation[] | Transaction + ): Promise { + let result = true; + + // Perform the edit in a transaction, then gather triggers, and recurse + const triggeringTransaction = async (executor: Executor): Promise => { + if (typeof providedEdit === 'function') { + // It's a transaction function + result = await providedEdit(executor); + } else { + // It's a command or JSON Patch. Just execute/apply it in the usual way + if (isModelServerCommand(providedEdit)) { + // Command case + await executor.execute(modelURI, providedEdit); + } else { + // JSON Patch case + await executor.applyPatch(providedEdit); + } + } + + return result; + }; + + return this.modelServerClient.openTransaction(modelURI).then(ctx => + triggeringTransaction(ctx) + .then(completeTransaction(ctx)) // The transaction context performs the triggers + .then(this.performPatchValidation(modelURI)) + .catch(error => ctx.rollback(error)) + ); + } + + /** + * Follow up patch of a model with validation of the same. + * + * @param modelURI the model patched + * @returns a function that performs live validation on a model update result if it was successful + */ + protected performPatchValidation(modelURI: string): (validate: ModelUpdateResult) => Promise { + const validator = this.validationManager; + + return async (validate: ModelUpdateResult) => { + if (validate.success) { + return validator.performLiveValidation(modelURI).then(() => validate); + } + this.logger.debug('Not validating the failed command/patch.'); + return validate; + }; + } +} + +function isCustomCommand(command: ModelServerCommand): boolean { + return !(SetCommand.is(command) || AddCommand.is(command) || RemoveCommand.is(command)); +} + +/** + * Complete a `transaction`. + * + * @param transaction the transaction context to complete + * @returns a function that takes a downstream response and returns a model update result, possibly rejected + */ +function completeTransaction(transaction: TransactionContext): (downstream: boolean) => Promise { + return async downstream => { + if (!downstream) { + const reason = 'Transaction failed'; + return transaction.rollback(reason); + } else { + return transaction.commit(); + } + }; +} diff --git a/packages/modelserver-node/src/services/model-service.spec.ts b/packages/modelserver-node/src/services/model-service.spec.ts new file mode 100644 index 0000000..cf012e8 --- /dev/null +++ b/packages/modelserver-node/src/services/model-service.spec.ts @@ -0,0 +1,598 @@ +/******************************************************************************** + * Copyright (c) 2022 STMicroelectronics. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0, or the MIT License which is + * available at https://opensource.org/licenses/MIT. + * + * SPDX-License-Identifier: EPL-2.0 OR MIT + *******************************************************************************/ +import { + CompoundCommand, + Diagnostic, + ModelServerCommand, + ModelServerNotificationListenerV2, + ModelServerObjectV2, + NotificationSubscriptionListenerV2, + Operations, + SetCommand, + SubscriptionListener, + WARNING +} from '@eclipse-emfcloud/modelserver-client'; +import { + CommandProvider, + ModelServerClientApi, + ModelService, + ModelServiceFactory, + TriggerProvider +} from '@eclipse-emfcloud/modelserver-plugin-ext'; +import * as chai from 'chai'; +import { expect } from 'chai'; +import * as chaiLike from 'chai-like'; +import { getValueByPointer, Operation } from 'fast-json-patch'; +import { Container } from 'inversify'; +import { Context } from 'mocha'; + +import { CommandProviderRegistry } from '../command-provider-registry'; +import { awaitClosed, CoffeeMachine, isCoffeeMachine, ServerFixture } from '../server-integration-test.spec'; +import { TriggerProviderRegistry } from '../trigger-provider-registry'; +import { ValidationProviderRegistry } from '../validation-provider-registry'; + +/** + * Integration tests for the `ModelService` API. + * + * These require the Example Coffee Model server from the `eclipse-emfcloud/emfcloud-modelserver` project to be + * running as the upstream Java server, listening on port 8081. + */ +export const integrationTests = undefined; + +chai.use(chaiLike); + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const pass = (): void => {}; + +describe('DefaultModelService', () => { + let assumeThat: (...args: Parameters) => void; + const modelURI = 'SuperBrewer3000.coffee'; + const diagnosticSource = 'Mocha Tests'; + let client: ModelServerClientApi; + let container: Container; // An independent client for model fixture maintenance + const server: ServerFixture = new ServerFixture(c => (container = c)); + server.requireUpstreamServer(); + + let modelService: ModelService; + let triggerReg: TriggerProviderRegistry; + let commandReg: CommandProviderRegistry; + + beforeEach(function () { + assumeThat = _assumeThat.bind(this); + const modelServiceFactory: ModelServiceFactory = container.get(ModelServiceFactory); + modelService = modelServiceFactory(modelURI); + client = container.get(ModelServerClientApi); + triggerReg = container.get(TriggerProviderRegistry); + commandReg = container.get(CommandProviderRegistry); + + const validationReg = container.get(ValidationProviderRegistry); + validationReg.register({ + canValidate: () => true, + validate: model => ({ + id: model.$id, + code: 1, + severity: WARNING, + source: diagnosticSource, + message: 'This is a fake warning.', + data: [], + children: [] + }) + }); + }); + + it('getModelURI()', () => { + expect(modelService.getModelURI()).to.be.eq(modelURI); + }); + + it('Model()', async () => { + const model = await modelService.getModel(); + + expect(model).to.be.an('object'); + expect(model).to.haveOwnProperty('$type', CoffeeMachine.TYPE); + }); + + it('getModel(format)', async () => { + const model = await modelService.getModel('json'); + + expect(model).to.be.an('object'); + expect(model).to.haveOwnProperty('eClass', CoffeeMachine.TYPE); + }); + + it('getModel(typeGuard)', async () => { + const model = await modelService.getModel(isCoffeeMachine); + + expect(model).to.haveOwnProperty('name', 'Super Brewer 3000'); + + const workflows = model['workflows']; + expect(workflows).to.be.an('array').that.is.not.empty; + expect(workflows[0]).to.be.like({ name: 'Simple Workflow', nodes: [{ name: 'PreHeat' }] }); + }); + + it('edit(patch)', async () => { + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + + const result = await modelService.edit(patch); + + try { + expect(result.patch).to.be.like([patch]); + } finally { + // Don't interfere with other tests + await client.undo(modelURI); + } + }); + + it('edit(patch) includes triggers', async () => { + const unregister = registerTrigger(triggerReg); + + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + const trigger: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name 1' }; + + try { + const result = await modelService.edit(patch); + expect(result.success).to.be.true; + expect(result.patch).to.be.like([patch, trigger]); + } finally { + unregister(); + + // Don't interfere with other tests + await client.undo(modelURI); + } + }); + + it('edit(command)', async () => { + const model = await client.get(modelURI); + expect(model['workflows']).to.be.an('array').that.is.not.empty; + const workflows = requireArray(model, 'workflows'); + const workflow = workflows[0] as ModelServerObjectV2; + const nodes = requireArray(workflow, 'nodes'); + const preheatTask = nodes[0] as ModelServerObjectV2; + + const command = new CompoundCommand(); + command.type = 'test-set-name'; + command.setProperty('newName', 'Heat Up First'); + command.owner = { + eClass: preheatTask.$type, + $ref: `${modelURI}#${preheatTask.$id}` + }; + + const unregister = registerCommand(commandReg); + + try { + const result = await modelService.edit(command); + expect(result.success).to.be.true; + expect(result.patch).to.be.like([{ op: 'replace', path: '/workflows/0/nodes/0/name', value: 'Heat Up First' }]); + } finally { + unregister(); + // Don't interfere with other tests + await client.undo(modelURI); + } + }); + + it('edit(command) includes triggers', async () => { + const model = await client.get(modelURI); + expect(model['workflows']).to.be.an('array').that.is.not.empty; + const workflows = requireArray(model, 'workflows'); + const workflow = workflows[0] as ModelServerObjectV2; + const nodes = requireArray(workflow, 'nodes'); + const preheatTask = nodes[0] as ModelServerObjectV2; + + const command = new CompoundCommand(); + command.type = 'test-set-name'; + command.setProperty('newName', 'Heat Up First'); + command.owner = { + eClass: preheatTask.$type, + $ref: `${modelURI}#${preheatTask.$id}` + }; + + const unregisterCommand = registerCommand(commandReg); + const unregisterTrigger = registerTrigger(triggerReg); + + try { + const result = await modelService.edit(command); + expect(result.success).to.be.true; + expect(result.patch).to.be.like([ + { op: 'replace', path: '/workflows/0/nodes/0/name', value: 'Heat Up First' }, + { op: 'replace', path: '/workflows/0/nodes/0/name', value: 'Heat Up First 1' } + ]); + } finally { + unregisterTrigger(); + unregisterCommand(); + // Don't interfere with other tests + await client.undo(modelURI); + } + }); + + it('undo()', async () => { + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + + const editResult = await modelService.edit(patch); + assumeThat(editResult.success, 'Edit failed.'); + + const undoResult = await modelService.undo(); + + expect(undoResult.success).to.be.true; + expect(undoResult.patch).to.be.like([{ op: 'replace', path: '/workflows/0/name', value: 'Simple Workflow' }]); + }); + + it('redo()', async () => { + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + + const editResult = await modelService.edit(patch); + assumeThat(editResult.success, 'Edit failed.'); + const undoResult = await modelService.undo(); + assumeThat(undoResult.success, 'Undo failed.'); + + const redoResult = await modelService.redo(); + + try { + expect(redoResult.success).to.be.true; + expect(redoResult.patch).to.be.like([patch]); + } finally { + // Don't interfere with other tests + await client.undo(modelURI); + } + }); + + it('validate()', async () => { + const result = findDiagnostic(await modelService.validate(), diagnosticSource); + + expect(result).to.be.like({ severity: WARNING, message: 'This is a fake warning.' }); + }); + + it('openTransaction() edit and roll back', async () => { + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + + const transaction = await modelService.openTransaction(); + expect(transaction.isOpen()).to.be.true; + expect(transaction.getModelURI()).to.be.string(modelURI); + + const result = await transaction.edit(patch); + expect(result.success).to.be.true; + expect(result.patch).to.be.like([patch]); + + await transaction.rollback('Testing roll-back.'); + await awaitClosed(transaction); + + const model = await client.get(modelURI); + const actual = getValueByPointer(model, '/workflows/0/name'); + expect(actual).to.be.string('Simple Workflow'); + }); + + it('openTransaction() edit and commit', async () => { + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + + const transaction = await modelService.openTransaction(); + expect(transaction.isOpen()).to.be.true; + + const result = await transaction.edit(patch); + expect(result.success).to.be.true; + expect(result.patch).to.be.like([patch]); + + await transaction.commit(); + + try { + await awaitClosed(transaction); + const model = await client.get(modelURI); + const actual = getValueByPointer(model, '/workflows/0/name'); + expect(actual).to.be.string('New Name'); + } finally { + // Don't interfere with other tests + await client.undo(modelURI); + } + }); + + it('openTransaction() child edit and roll back', async () => { + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + + const transaction = await modelService.openTransaction(); + assumeThat(transaction.isOpen(), 'Parent transaction not opened.'); + + const child = await modelService.openTransaction(); + expect(child.isOpen()).to.be.true; + expect(child).not.to.be.equal(transaction); + + const result = await child.edit(patch); + expect(result.success).to.be.true; + expect(result.patch).to.be.like([patch]); + + await child.rollback('Testing roll-back.'); + await awaitClosed(transaction); + + expect(child.isOpen()).to.be.false; + expect(transaction.isOpen()).to.be.false; + + const model = await client.get(modelURI); + const actual = getValueByPointer(model, '/workflows/0/name'); + expect(actual).to.be.string('Simple Workflow'); + }); + + it('openTransaction() child edit and commit', async () => { + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + + const transaction = await modelService.openTransaction(); + assumeThat(transaction.isOpen(), 'Parent transaction not opened.'); + + const child = await modelService.openTransaction(); + expect(child.isOpen()).to.be.true; + expect(child).not.to.be.equal(transaction); + + const result = await child.edit(patch); + expect(result.success).to.be.true; + expect(result.patch).to.be.like([patch]); + + const committed = await child.commit(); + expect(committed.patch).to.be.like([patch]); + + expect(transaction.isOpen()).to.be.true; + const aggregate = await transaction.commit(); + expect(aggregate.patch).to.be.like([patch]); + + try { + await awaitClosed(transaction); + const model = await client.get(modelURI); + const actual = getValueByPointer(model, '/workflows/0/name'); + expect(actual).to.be.string('New Name'); + } finally { + // Don't interfere with other tests + await client.undo(modelURI); + } + }); + + it('openTransaction() child edit and commit but roll back parent', async () => { + const patch: Operation = { op: 'replace', path: '/workflows/0/name', value: 'New Name' }; + + const transaction = await modelService.openTransaction(); + assumeThat(transaction.isOpen(), 'Parent transaction not opened.'); + + const child = await modelService.openTransaction(); + expect(child.isOpen()).to.be.true; + expect(child).not.to.be.equal(transaction); + + const result = await child.edit(patch); + expect(result.success).to.be.true; + expect(result.patch).to.be.like([patch]); + + const committed = await child.commit(); + expect(committed.patch).to.be.like([patch]); + + expect(transaction.isOpen()).to.be.true; + await transaction.rollback('Testing parent roll-back.'); + await awaitClosed(transaction); + + const model = await client.get(modelURI); + const actual = getValueByPointer(model, '/workflows/0/name'); + expect(actual).to.be.string('Simple Workflow'); + }); + + describe('Destructive APIs', () => { + const newModelURI = 'ModelServiceTest.coffee'; + const modelContent: Partial = { + $type: CoffeeMachine.TYPE, + name: 'New Coffee Machine' + }; + let newModelService: ModelService; + + beforeEach(function () { + assumeThat = _assumeThat.bind(this); + const modelServiceFactory: ModelServiceFactory = container.get(ModelServiceFactory); + newModelService = modelServiceFactory(newModelURI); + }); + + afterEach(async () => { + const uris = await client.getModelUris(); + if (!uris.includes(newModelURI)) { + return Promise.resolve(); + } else { + // Clean up this model + return client.delete(newModelURI).catch(pass); + } + }); + + it('create()', async () => { + const result = await newModelService.create(modelContent); + expect(result).to.be.like(modelContent); + + const actual = await client.get(newModelURI, isCoffeeMachine); + expect(actual).to.be.like(modelContent); + }); + + it('close()', async () => { + const model = await newModelService.create(modelContent); + assumeThat(!!model, 'Model not created.'); + + const { ready, done: closed } = listenForFullUpdate(client, newModelURI, 'close'); + await ready; + + try { + const result = await newModelService.close(); + expect(result).to.be.true; + + const actual = await closed; + expect(actual).to.be.true; + } finally { + client.unsubscribe(newModelURI); + } + }); + + it('save()', async () => { + const model = await newModelService.create(modelContent); + assumeThat(!!model, 'Model not created.'); + + const patch: Operation = { op: 'replace', path: '/name', value: 'New Name' }; + const edited = await client.edit(newModelURI, patch); + assumeThat(edited.success, 'Model edit failed.'); + + const dirtyState = new Promise(resolve => { + const listener: ModelServerNotificationListenerV2 = { + onError: notif => { + expect.fail(`Error in ${notif.modelUri} subscription: ${notif.error}`); + }, + onDirtyStateChanged: notif => { + if (!notif.isDirty) { + resolve(notif.isDirty); + } + } + }; + + client.subscribe(newModelURI, new NotificationSubscriptionListenerV2(listener)); + }); + + try { + const result = await newModelService.save(); + expect(result).to.be.true; + + const actual = await dirtyState; + expect(actual).to.be.false; + } finally { + client.unsubscribe(newModelURI); + } + }); + + it('delete()', async () => { + const model = await newModelService.create(modelContent); + assumeThat(!!model, 'Model not created.'); + + const { ready, done: deleted } = listenForFullUpdate(client, newModelURI, 'delete'); + await ready; + + try { + const result = await newModelService.delete(); + expect(result).to.be.true; + + const actual = await deleted; + expect(actual).to.be.true; + } finally { + client.unsubscribe(newModelURI); + } + }); + }); +}); + +function requireArray(owner: object, propertyName: string): unknown[] { + expect(owner[propertyName]).to.be.an('array').that.is.not.empty; + return owner[propertyName] as unknown[]; +} + +function _assumeThat(this: Context, condition: boolean, reason: string): void { + if (!condition) { + if (this.test) { + this.test.title = `${this.test.title} - skipped: ${reason}`; + } + this.skip(); + } +} + +function findDiagnostic(diagnostic: Diagnostic, source: string): Diagnostic | undefined { + if (diagnostic.source === source) { + return diagnostic; + } + + for (const child of diagnostic.children) { + const result = findDiagnostic(child, source); + if (result) { + return result; + } + } + + return undefined; +} + +/** + * Register a test trigger. + * + * @param triggerRegistry the trigger registry + * @returns a function that unregisters the test trigger + */ +function registerTrigger(triggerRegistry: TriggerProviderRegistry): () => void { + const endsWithNumber = (s: string): boolean => /\d+$/.test(s); + + const triggerProvider: TriggerProvider = { + canTrigger: () => true, + getTriggers: (_modelURI, modelDelta) => { + if (modelDelta.length === 1 && Operations.isReplace(modelDelta[0], 'string') && !endsWithNumber(modelDelta[0].value)) { + return [ + { + op: 'replace', + path: modelDelta[0].path, + value: `${modelDelta[0].value} 1` + } + ]; + } + return []; + } + }; + + const id = triggerRegistry.register(triggerProvider); + + return () => triggerRegistry.unregister(id, triggerProvider); +} + +/** + * Register a test command. + * + * @param commandRegistry the command registry + * @returns a function that unregisters the test command + */ +function registerCommand(commandRegistry: CommandProviderRegistry): () => void { + const provider: CommandProvider = { + canHandle: () => true, + getCommands: (_modelUri, customCommand: ModelServerCommand) => + new SetCommand(customCommand.owner!, 'name', [customCommand.getProperty('newName') as string]) + }; + commandRegistry.register('test-set-name', provider); + + return () => commandRegistry.unregister('test-set-name', provider); +} + +/** + * Listen for the full-update message that signals either close or deletion of a model, + * according to the requested `mode`. + * + * @param client the model server client on which to add a subscription listener + * @param modelURI the model on which to add a subscription listener + * @param mode the wait mode + * @returns a promise to await to synchronize with listener attachment and another to + * await to synchronize with receipt of the matching full-update message + */ +function listenForFullUpdate( + client: ModelServerClientApi, + modelURI: string, + mode: 'close' | 'delete' +): { ready: Promise; done: Promise } { + let result: Promise; + const listening = new Promise(resolveListening => { + result = new Promise(resolveResult => { + // Cannot use the NotificationSubscriptionListenerV2 API to listen for model + // close or delete because it throws an error on attempt to map the null model + // int the full-update message that signals close/delete + const listener: SubscriptionListener = { + onError: (uri, event) => { + expect.fail(`Error in ${uri} subscription: ${event.error}`); + }, + onMessage: (_uri, event) => { + const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + // Close doesn't, in practice, yield a `null` update because the model controller + // immediately re-loads the resource from storage. + // eslint-disable-next-line no-null/no-null + if (data.type === 'fullUpdate' && (mode === 'close' || data.data == null)) { + resolveResult(true); + } + }, + onOpen: () => resolveListening(true), + onClose: pass + }; + + client.subscribe(modelURI, listener); + }); + }); + + return { ready: listening, done: result! }; +} diff --git a/packages/modelserver-node/src/services/model-service.ts b/packages/modelserver-node/src/services/model-service.ts new file mode 100644 index 0000000..86b3dd0 --- /dev/null +++ b/packages/modelserver-node/src/services/model-service.ts @@ -0,0 +1,110 @@ +/******************************************************************************** + * Copyright (c) 2022 STMicroelectronics. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0, or the MIT License which is + * available at https://opensource.org/licenses/MIT. + * + * SPDX-License-Identifier: EPL-2.0 OR MIT + *******************************************************************************/ + +import { + AnyObject, + Diagnostic, + FORMAT_JSON_V2, + ModelServerCommand, + ModelUpdateResult, + TypeGuard +} from '@eclipse-emfcloud/modelserver-client'; +import { EditTransaction, Logger, ModelService } from '@eclipse-emfcloud/modelserver-plugin-ext'; +import { Operation } from 'fast-json-patch'; +import { inject, injectable, named } from 'inversify'; + +import { InternalModelServerClientApi } from '../client/model-server-client'; +import { EditService } from './edit-service'; +import { ValidationManager } from './validation-manager'; + +/** Injection key for the model URI that the model service accesses. */ +export const MODEL_URI = Symbol('MODEL_URI'); + +@injectable() +export class DefaultModelService implements ModelService { + @inject(Logger) + @named(DefaultModelService.name) + protected readonly logger: Logger; + + @inject(MODEL_URI) + protected readonly modeluri: string; + + @inject(InternalModelServerClientApi) + protected readonly client: InternalModelServerClientApi; + + @inject(ValidationManager) + protected readonly validator: ValidationManager; + + @inject(EditService) + protected readonly editService: EditService; + + getModelURI(): string { + return this.modeluri; + } + + getModel(format?: string): Promise; + getModel(typeGuard: TypeGuard, format?: string): Promise; + getModel(typeGuardOrFormat: TypeGuard | string, format?: string): Promise { + if (typeof typeGuardOrFormat === 'string') { + // It's the first signature with a format + return this.client.get(this.getModelURI(), typeGuardOrFormat); + } + if (typeGuardOrFormat) { + // It's the second signature and the format is easy to default + return this.client.get(this.getModelURI(), typeGuardOrFormat, format ?? FORMAT_JSON_V2); + } + // It's the first signature without a format + return this.client.get(this.getModelURI(), FORMAT_JSON_V2); + } + + edit(patch: Operation | Operation[]): Promise; + edit(command: ModelServerCommand): Promise; + edit(patchOrCommand: Operation | Operation[] | ModelServerCommand): Promise { + return this.editService.edit(this.getModelURI(), patchOrCommand); + } + + undo(): Promise { + return this.client.undo(this.getModelURI()); + } + + redo(): Promise { + return this.client.redo(this.getModelURI()); + } + + openTransaction(): Promise { + return this.client.openTransaction(this.getModelURI()); + } + + validate(): Promise { + return this.validator.validate(this.getModelURI()); + } + + async create(content: M, format?: string): Promise { + return this.client.create(this.getModelURI(), content, format ?? FORMAT_JSON_V2).then(success => { + if (success) { + return content; + } + return Promise.reject('Model not created.'); + }); + } + + save(): Promise { + return this.client.save(this.getModelURI()); + } + + close(): Promise { + return this.client.close(this.getModelURI()); + } + + delete(): Promise { + return this.client.delete(this.getModelURI()); + } +} diff --git a/packages/modelserver-plugin-ext/src/index.ts b/packages/modelserver-plugin-ext/src/index.ts index 71795d8..71f2e4f 100644 --- a/packages/modelserver-plugin-ext/src/index.ts +++ b/packages/modelserver-plugin-ext/src/index.ts @@ -13,6 +13,7 @@ export * from './command-provider'; export * from './executor'; export * from './logger'; export * from './model-server-client'; +export * from './model-service'; export * from './plugin'; export * from './route-provider'; export * from './trigger-provider'; diff --git a/packages/modelserver-plugin-ext/src/model-service.ts b/packages/modelserver-plugin-ext/src/model-service.ts new file mode 100644 index 0000000..a483f5d --- /dev/null +++ b/packages/modelserver-plugin-ext/src/model-service.ts @@ -0,0 +1,203 @@ +/******************************************************************************** + * Copyright (c) 2022 STMicroelectronics. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0, or the MIT License which is + * available at https://opensource.org/licenses/MIT. + * + * SPDX-License-Identifier: EPL-2.0 OR MIT + *******************************************************************************/ + +import { AnyObject, Diagnostic, Format, ModelServerCommand, ModelUpdateResult, TypeGuard } from '@eclipse-emfcloud/modelserver-client'; +import { Operation } from 'fast-json-patch'; + +export const ModelServiceFactory = Symbol('ModelServiceFactory'); +export const ModelService = Symbol('ModelService'); + +/** + * A factory that obtains the `ModelService` for a particular model URI. + */ +export type ModelServiceFactory = (modeluri: string) => ModelService; + +/** + * A service that plug-in extensions may use to create, access, and modify a model. + * Whereas it is not defined how much local caching of model state may be implemented + * by this service, ranging from pure pass-through of requests to the back-end to a + * complete in-memory replica of model content and edit state, all access to the + * model should be performed through this service and not directly via a + * `ModelServerClientApi` connection to the Java (EMF) Server. + * + * In effect, this service is a proxy for the remote _Model Server_ API of host server itself, + * accessible in-process. + */ +export interface ModelService { + /** + * Query the URI of the model that I access. + * + * @returns my model's URI + */ + getModelURI(): string; + + /** + * Get the content of my model. If the `format` is omitted and a request needs to be + * sent to retrieve the model from the EMF server, then `json-v2` is implied. + * + * @param format the optional format of the request to send to the underlying Java (EMF) server in case the model needs to be retrieved from it + * @returns the model content + */ + getModel(format?: Format): Promise; + + /** + * Get the content of my model. If the `format` is omitted and a request needs to be + * sent to retrieve the model from the EMF server, then `json-v2` is implied. + * + * @param typeGuard a type guard to coerce the model into the expected type + * @param format the optional format of the request to send to the underlying Java (EMF) server in case the model needs to be retrieved from it + * @returns the model content, if it conforms to the type guard + */ + getModel(typeGuard: TypeGuard, format?: Format): Promise; + + /** + * Apply a JSON patch to edit my model, including side-effects such as triggers + * contributed by plug-ins in the host server. + * + * _Note_ that if a {@link openTransaction transaction} is currently open on the given model, + * then the `patch` is applied in the context of that transaction. + * + * @param patch the JSON patch to apply to the model + * @returns the result of the model edit, including any consequent triggers, if applicable + */ + edit(patch: Operation | Operation[]): Promise; + + /** + * Execute a command to edit my model, with support for custom commands implemented by + * plug-ins in the host server. + * + * _Note_ that if a {@link openTransaction transaction} is currently open on the given model, + * then the `command` is executed in the context of that transaction. + * + * @param command the command to execute on the model + * @returns the result of the model edit, including any consequent triggers, if applicable + */ + edit(command: ModelServerCommand): Promise; + + /** + * Undo the last change made to my model. + * + * @returns a description of the changes in the model induced by the undo + */ + undo(): Promise; + + /** + * Redo the last change undone in my model. + * + * @returns a description of the changes in the model induced by the redo + */ + redo(): Promise; + + /** + * Open a transaction for multiple sequential edits on my model to be collected into an + * atomic unit of undo/redo. Unlike the underlying _Model Server_ protocol, these transactions + * may be nested: if a transaction is already open on some model, a nested transaction may + * be opened that commits into the context of the parent transaction. All changes performed + * in the context of a top-level transaction are collected into an atomic change on the + * undo stack. + * + * @returns the new transaction + */ + openTransaction(): Promise; + + /** + * Validate my model, including any custom validation rules contributed by plug-ins + * in the host server. + * + * @returns the validation results + */ + validate(): Promise; + + /** + * Create my model in the underlying Java (EMF) model server. + * If omitted, the `format` is implied to be `json-v2`. + * + * @param content the content of the model + * @param format the optional format of the request to send to the underlying Java (EMF) server + * @returns the created model content, or a rejected promise if the model already exists + */ + create(content: M, format?: Format): Promise; + + /** + * Save my model's current state. + * + * @returns whether the save succeeded. If `true` then the latest model state is guaranteed to be persistent + */ + save(): Promise; + + /** + * Close my model and forget any server state associated with it. + * + * @returns whether the close succeeded + */ + close(): Promise; + + /** + * Delete my model from persistent storage and forget any server state associated with it. + * + * @returns whether the deletion succeeded. If `true` then the model state is guaranteed not to be persistent + */ + delete(): Promise; +} + +/** + * Protocol for the client-side context of an open transaction on a model URI. + * While a transaction is open, {@link ModelService.edit} operations on the model + * are collected in the transaction context for atomic undo/redo when eventually + * it is committed. + */ +export interface EditTransaction { + /** + * Query the URI of the model that this transaction edits. + */ + getModelURI(): string; + + /** + * Query whether the transaction is currently open. + */ + isOpen(): boolean; + + /** + * Apply a JSON patch to edit the model, including side-effects such as triggers + * contributed by plug-ins in the host server. + * + * @param patch the JSON patch to apply to the model + * @returns the result of the model edit, including any consequent triggers, if applicable + */ + edit(patch: Operation | Operation[]): Promise; + + /** + * Execute a command to edit the model, with support for custom commands implemented by + * plug-ins in the host server. + * + * @param command the command to execute on the model + * @returns the result of the model edit, including any consequent triggers, if applicable + */ + edit(command: ModelServerCommand): Promise; + + /** + * Close the transaction, putting the compound of all commands executed within its span + * onto the stack for undo/redo. If the transaction is nested in another transaction, + * then its changes are committed into the nesting transaction, which remains open. + * + * @returns the aggregate result of changes performed during the transaction + */ + commit(): Promise; + + /** + * Undo all changes performed during the transaction and discard them. + * The transaction is closed. + * + * @param error the reason for the rollback + * @returns a failed update result + */ + rollback(error: any): Promise; +} diff --git a/yarn.lock b/yarn.lock index fe1cfb9..33bdc6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1196,85 +1196,85 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@^5.4.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.13.0.tgz#2809052b85911ced9c54a60dac10e515e9114497" - integrity sha512-vLktb2Uec81fxm/cfz2Hd6QaWOs8qdmVAZXLdOBX6JFJDhf6oDZpMzZ4/LZ6SFM/5DgDcxIMIvy3F+O9yZBuiQ== - dependencies: - "@typescript-eslint/scope-manager" "5.13.0" - "@typescript-eslint/type-utils" "5.13.0" - "@typescript-eslint/utils" "5.13.0" - debug "^4.3.2" - functional-red-black-tree "^1.0.1" - ignore "^5.1.8" +"@typescript-eslint/eslint-plugin@^5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz#9f05d42fa8fb9f62304cc2f5c2805e03c01c2620" + integrity sha512-ky7EFzPhqz3XlhS7vPOoMDaQnQMn+9o5ICR9CPr/6bw8HrFkzhMSxuA3gRfiJVvs7geYrSeawGJjZoZQKCOglQ== + dependencies: + "@typescript-eslint/scope-manager" "5.38.1" + "@typescript-eslint/type-utils" "5.38.1" + "@typescript-eslint/utils" "5.38.1" + debug "^4.3.4" + ignore "^5.2.0" regexpp "^3.2.0" - semver "^7.3.5" + semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@^5.4.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.13.0.tgz#0394ed8f2f849273c0bf4b811994d177112ced5c" - integrity sha512-GdrU4GvBE29tm2RqWOM0P5QfCtgCyN4hXICj/X9ibKED16136l9ZpoJvCL5pSKtmJzA+NRDzQ312wWMejCVVfg== +"@typescript-eslint/parser@^5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.38.1.tgz#c577f429f2c32071b92dff4af4f5fbbbd2414bd0" + integrity sha512-LDqxZBVFFQnQRz9rUZJhLmox+Ep5kdUmLatLQnCRR6523YV+XhRjfYzStQ4MheFA8kMAfUlclHSbu+RKdRwQKw== dependencies: - "@typescript-eslint/scope-manager" "5.13.0" - "@typescript-eslint/types" "5.13.0" - "@typescript-eslint/typescript-estree" "5.13.0" - debug "^4.3.2" + "@typescript-eslint/scope-manager" "5.38.1" + "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/typescript-estree" "5.38.1" + debug "^4.3.4" -"@typescript-eslint/scope-manager@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.13.0.tgz#cf6aff61ca497cb19f0397eea8444a58f46156b6" - integrity sha512-T4N8UvKYDSfVYdmJq7g2IPJYCRzwtp74KyDZytkR4OL3NRupvswvmJQJ4CX5tDSurW2cvCc1Ia1qM7d0jpa7IA== +"@typescript-eslint/scope-manager@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.38.1.tgz#f87b289ef8819b47189351814ad183e8801d5764" + integrity sha512-BfRDq5RidVU3RbqApKmS7RFMtkyWMM50qWnDAkKgQiezRtLKsoyRKIvz1Ok5ilRWeD9IuHvaidaLxvGx/2eqTQ== dependencies: - "@typescript-eslint/types" "5.13.0" - "@typescript-eslint/visitor-keys" "5.13.0" + "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/visitor-keys" "5.38.1" -"@typescript-eslint/type-utils@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.13.0.tgz#b0efd45c85b7bab1125c97b752cab3a86c7b615d" - integrity sha512-/nz7qFizaBM1SuqAKb7GLkcNn2buRdDgZraXlkhz+vUGiN1NZ9LzkA595tHHeduAiS2MsHqMNhE2zNzGdw43Yg== +"@typescript-eslint/type-utils@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.38.1.tgz#7f038fcfcc4ade4ea76c7c69b2aa25e6b261f4c1" + integrity sha512-UU3j43TM66gYtzo15ivK2ZFoDFKKP0k03MItzLdq0zV92CeGCXRfXlfQX5ILdd4/DSpHkSjIgLLLh1NtkOJOAw== dependencies: - "@typescript-eslint/utils" "5.13.0" - debug "^4.3.2" + "@typescript-eslint/typescript-estree" "5.38.1" + "@typescript-eslint/utils" "5.38.1" + debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.13.0.tgz#da1de4ae905b1b9ff682cab0bed6b2e3be9c04e5" - integrity sha512-LmE/KO6DUy0nFY/OoQU0XelnmDt+V8lPQhh8MOVa7Y5k2gGRd6U9Kp3wAjhB4OHg57tUO0nOnwYQhRRyEAyOyg== +"@typescript-eslint/types@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.38.1.tgz#74f9d6dcb8dc7c58c51e9fbc6653ded39e2e225c" + integrity sha512-QTW1iHq1Tffp9lNfbfPm4WJabbvpyaehQ0SrvVK2yfV79SytD9XDVxqiPvdrv2LK7DGSFo91TB2FgWanbJAZXg== -"@typescript-eslint/typescript-estree@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.13.0.tgz#b37c07b748ff030a3e93d87c842714e020b78141" - integrity sha512-Q9cQow0DeLjnp5DuEDjLZ6JIkwGx3oYZe+BfcNuw/POhtpcxMTy18Icl6BJqTSd+3ftsrfuVb7mNHRZf7xiaNA== +"@typescript-eslint/typescript-estree@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.38.1.tgz#657d858d5d6087f96b638ee383ee1cff52605a1e" + integrity sha512-99b5e/Enoe8fKMLdSuwrfH/C0EIbpUWmeEKHmQlGZb8msY33qn1KlkFww0z26o5Omx7EVjzVDCWEfrfCDHfE7g== dependencies: - "@typescript-eslint/types" "5.13.0" - "@typescript-eslint/visitor-keys" "5.13.0" - debug "^4.3.2" - globby "^11.0.4" + "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/visitor-keys" "5.38.1" + debug "^4.3.4" + globby "^11.1.0" is-glob "^4.0.3" - semver "^7.3.5" + semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.13.0.tgz#2328feca700eb02837298339a2e49c46b41bd0af" - integrity sha512-+9oHlPWYNl6AwwoEt5TQryEHwiKRVjz7Vk6kaBeD3/kwHE5YqTGHtm/JZY8Bo9ITOeKutFaXnBlMgSATMJALUQ== +"@typescript-eslint/utils@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.38.1.tgz#e3ac37d7b33d1362bb5adf4acdbe00372fb813ef" + integrity sha512-oIuUiVxPBsndrN81oP8tXnFa/+EcZ03qLqPDfSZ5xIJVm7A9V0rlkQwwBOAGtrdN70ZKDlKv+l1BeT4eSFxwXA== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.13.0" - "@typescript-eslint/types" "5.13.0" - "@typescript-eslint/typescript-estree" "5.13.0" + "@typescript-eslint/scope-manager" "5.38.1" + "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/typescript-estree" "5.38.1" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/visitor-keys@5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.13.0.tgz#f45ff55bcce16403b221ac9240fbeeae4764f0fd" - integrity sha512-HLKEAS/qA1V7d9EzcpLFykTePmOQqOFim8oCvhY3pZgQ8Hi38hYpHd9e5GN6nQBFQNecNhws5wkS9Y5XIO0s/g== +"@typescript-eslint/visitor-keys@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.38.1.tgz#508071bfc6b96d194c0afe6a65ad47029059edbc" + integrity sha512-bSHr1rRxXt54+j2n4k54p4fj8AHJ49VDWtjpImOpzQj4qjAiOpPni+V1Tyajh19Api1i844F757cur8wH3YvOA== dependencies: - "@typescript-eslint/types" "5.13.0" - eslint-visitor-keys "^3.0.0" + "@typescript-eslint/types" "5.38.1" + eslint-visitor-keys "^3.3.0" "@ungap/promise-all-settled@1.1.2": version "1.1.2" @@ -2099,6 +2099,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -2489,7 +2496,7 @@ eslint-visitor-keys@^2.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.3.0: +eslint-visitor-keys@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== @@ -3051,7 +3058,7 @@ globals@^13.6.0, globals@^13.9.0: dependencies: type-fest "^0.20.2" -globby@^11.0.2, globby@^11.0.4: +globby@^11.0.2, globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -3249,7 +3256,7 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.1.8, ignore@^5.2.0: +ignore@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== @@ -5294,6 +5301,13 @@ semver@^7.1.1, semver@^7.1.3, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: dependencies: lru-cache "^6.0.0" +semver@^7.3.7: + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== + dependencies: + lru-cache "^6.0.0" + send@0.17.2: version "0.17.2" resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"