UNPKG

@eclipse-emfcloud/modelserver-client

Version:

Typescript rest client to interact with an EMF.cloud modelserver

372 lines (337 loc) 16.3 kB
/******************************************************************************** * Copyright (c) 2022-2023 STMicroelectronics and others. * * 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 axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import { deepClone } from 'fast-json-patch'; import WebSocket from 'isomorphic-ws'; import URI from 'urijs'; import { ModelServerError, ServerConfiguration, SubscriptionOptions } from './model-server-client-api-v1'; import { Format, FORMAT_JSON_V2, ModelServerClientApiV2, ModelUpdateResult, PatchOrCommand } from './model-server-client-api-v2'; import { MessageDataMapper, Model, ModelServerMessage } from './model-server-message'; import { ModelServerPaths } from './model-server-paths'; import { ModelServerCommand } from './model/command-model'; import { Diagnostic } from './model/diagnostic'; import { SubscriptionListener } from './subscription-listener'; import { AnyObject, asObject, asString, asType, encodeRequestBody, TypeGuard } from './utils/type-util'; /** * A client to interact with a model server. */ export class ModelServerClientV2 implements ModelServerClientApiV2 { protected restClient: AxiosInstance; protected openSockets: Map<string, WebSocket> = new Map(); protected _baseUrl: URI; protected defaultFormat: Format = FORMAT_JSON_V2; initialize(baseUrl: URI, defaultFormat: Format = FORMAT_JSON_V2): void | Promise<void> { this._baseUrl = baseUrl.clone(); this.defaultFormat = defaultFormat; this.restClient = axios.create(this.getAxiosConfig(baseUrl)); } protected getAxiosConfig(baseURL: URI): AxiosRequestConfig | undefined { return { baseURL: baseURL.toString() }; } get(modeluri: URI, format?: Format): Promise<AnyObject>; get<M>(modeluri: URI, typeGuard: TypeGuard<M>, format?: Format): Promise<M>; get<M>(modeluri: URI, formatOrGuard?: FormatOrGuard<M>, format?: Format): Promise<AnyObject | M> { if (typeof formatOrGuard === 'function') { const typeGuard = formatOrGuard; return this.process( this.restClient.get(ModelServerPaths.MODEL_CRUD, { params: { modeluri: modeluri.toString(), format } }), msg => MessageDataMapper.as(msg, typeGuard) ); } format = formatOrGuard ?? this.defaultFormat; return this.process( this.restClient.get(ModelServerPaths.MODEL_CRUD, { params: { modeluri: modeluri.toString(), format } }), MessageDataMapper.asObject ); } getAll(): Promise<Model[]>; getAll<M>(typeGuard: TypeGuard<M>): Promise<Model<M>[]>; getAll(format: Format): Promise<Model<string>[]>; getAll<M>(formatOrGuard?: FormatOrGuard<M>): Promise<Array<Model | Model<string> | Model<M>>> { let modelMapper: (model: Model) => Model<string | AnyObject | M>; let format = this.defaultFormat; if (!formatOrGuard) { modelMapper = (model: Model) => mapModel(model); } else if (typeof formatOrGuard === 'string') { format = formatOrGuard; modelMapper = (model: Model) => mapModel(model, undefined, true); } else { modelMapper = (model: Model) => mapModel(model, formatOrGuard); } const messageMapper = (message: ModelServerMessage): Array<Model | Model<string> | Model<M>> => MessageDataMapper.asModelArray(message).map(modelMapper); return this.process(this.restClient.get(ModelServerPaths.MODEL_CRUD, { params: { format } }), messageMapper); } getModelUris(): Promise<URI[]> { return this.process(this.restClient.get(ModelServerPaths.MODEL_URIS), MessageDataMapper.asURIArray); } getElementById(modeluri: URI, elementid: string, format?: Format): Promise<AnyObject>; getElementById<M>(modeluri: URI, elementid: string, typeGuard: TypeGuard<M>): Promise<M>; getElementById<M>(modeluri: URI, elementid: string, formatOrGuard?: FormatOrGuard<M>, format?: string): Promise<AnyObject | M> { format = format ?? this.defaultFormat; if (formatOrGuard) { if (typeof formatOrGuard === 'function') { const typeGuard = formatOrGuard; return this.process( this.restClient.get(ModelServerPaths.MODEL_ELEMENT, { params: { modeluri: modeluri.toString(), elementid, format } }), msg => MessageDataMapper.as(msg, typeGuard) ); } format = formatOrGuard; } return this.process( this.restClient.get(ModelServerPaths.MODEL_ELEMENT, { params: { modeluri: modeluri.toString(), elementid, format } }), MessageDataMapper.asObject ); } getElementByName(modeluri: URI, elementname: string, format?: Format): Promise<AnyObject>; getElementByName<M>(modeluri: URI, elementname: string, typeGuard: TypeGuard<M>, format?: Format): Promise<M>; getElementByName<M>(modeluri: URI, elementname: string, formatOrGuard?: FormatOrGuard<M>, format?: Format): Promise<AnyObject | M> { format = format ?? this.defaultFormat; if (formatOrGuard) { if (typeof formatOrGuard === 'function') { const typeGuard = formatOrGuard; return this.process( this.restClient.get(ModelServerPaths.MODEL_ELEMENT, { params: { modeluri: modeluri.toString(), elementname, format } }), msg => MessageDataMapper.as(msg, typeGuard) ); } format = formatOrGuard; } return this.process( this.restClient.get(ModelServerPaths.MODEL_ELEMENT, { params: { modeluri: modeluri.toString(), elementname, format } }), MessageDataMapper.asObject ); } create(modeluri: URI, model: AnyObject | string, format?: Format): Promise<AnyObject>; create<M>(modeluri: URI, model: AnyObject | string, typeGuard: TypeGuard<M>, format?: Format): Promise<M>; create<M>(modeluri: URI, model: AnyObject | string, formatOrGuard?: FormatOrGuard<M>, format?: Format): Promise<AnyObject | M> { format = format ?? this.defaultFormat; if (formatOrGuard) { if (typeof formatOrGuard === 'function') { const typeGuard = formatOrGuard; return this.process( this.restClient.post(ModelServerPaths.MODEL_CRUD, encodeRequestBody(format)(model), { params: { modeluri: modeluri.toString(), format } }), msg => MessageDataMapper.as(msg, typeGuard) ); } format = formatOrGuard; } return this.process( this.restClient.post(ModelServerPaths.MODEL_CRUD, encodeRequestBody(format)(model), { params: { modeluri: modeluri.toString(), format } }), MessageDataMapper.asObject ); } update(modeluri: URI, model: AnyObject | string, format?: Format): Promise<AnyObject>; update<M>(modeluri: URI, model: string | string, typeGuard: TypeGuard<M>, format?: Format): Promise<M>; update<M>( modeluri: URI, model: AnyObject | string, formatOrGuard?: FormatOrGuard<M>, format?: Format ): Promise<AnyObject> | Promise<M> { format = format ?? this.defaultFormat; if (formatOrGuard) { if (typeof formatOrGuard === 'function') { const typeGuard = formatOrGuard; return this.process( this.restClient.put(ModelServerPaths.MODEL_CRUD, encodeRequestBody(format)(model), { params: { modeluri: modeluri.toString(), format } }), msg => MessageDataMapper.as(msg, typeGuard) ); } format = formatOrGuard; } return this.process( this.restClient.put(ModelServerPaths.MODEL_CRUD, encodeRequestBody(format)(model), { params: { modeluri: modeluri.toString(), format } }), MessageDataMapper.asObject ); } delete(modeluri: URI): Promise<boolean> { return this.process( this.restClient.delete(ModelServerPaths.MODEL_CRUD, { params: { modeluri: modeluri.toString() } }), MessageDataMapper.isSuccess ); } close(modeluri: URI): Promise<boolean> { return this.process( this.restClient.post(ModelServerPaths.CLOSE, undefined, { params: { modeluri: modeluri.toString() } }), MessageDataMapper.isSuccess ); } save(modeluri: URI): Promise<boolean> { return this.process( this.restClient.get(ModelServerPaths.SAVE, { params: { modeluri: modeluri.toString() } }), MessageDataMapper.isSuccess ); } saveAll(): Promise<boolean> { return this.process(this.restClient.get(ModelServerPaths.SAVE_ALL), MessageDataMapper.isSuccess); } validate(modeluri: URI): Promise<Diagnostic> { return this.process(this.restClient.get(ModelServerPaths.VALIDATION, { params: { modeluri: modeluri.toString() } }), response => MessageDataMapper.as(response, Diagnostic.is) ); } getValidationConstraints(modeluri: URI): Promise<string> { return this.process( this.restClient.get(ModelServerPaths.VALIDATION_CONSTRAINTS, { params: { modeluri: modeluri.toString() } }), MessageDataMapper.asString ); } getTypeSchema(modeluri: URI): Promise<string> { return this.process( this.restClient.get(ModelServerPaths.TYPE_SCHEMA, { params: { modeluri: modeluri.toString() } }), MessageDataMapper.asString ); } getUiSchema(schemaname: string): Promise<string> { return this.process(this.restClient.get(ModelServerPaths.UI_SCHEMA, { params: { schemaname } }), MessageDataMapper.asString); } configureServer(configuration: ServerConfiguration): Promise<boolean> { return this.process( this.restClient.put(ModelServerPaths.SERVER_CONFIGURE, { workspaceRoot: configuration.workspaceRoot.toString(), uiSchemaFolder: configuration.uiSchemaFolder?.toString() }), MessageDataMapper.isSuccess ); } ping(): Promise<boolean> { return this.process(this.restClient.get(ModelServerPaths.SERVER_PING), MessageDataMapper.isSuccess); } edit(modeluri: URI, patchOrCommand: PatchOrCommand, format = this.defaultFormat): Promise<ModelUpdateResult> { let patchMessage: any; if (patchOrCommand instanceof ModelServerCommand) { patchMessage = { type: 'modelserver.emfcommand', data: patchOrCommand }; } else { // Operation[] and ModelPatch[] are treated in the same way; we don't need to distinguish both cases const fullPatch = Array.isArray(patchOrCommand) ? patchOrCommand : [patchOrCommand]; patchMessage = { type: 'modelserver.patch', data: fullPatch }; if (fullPatch.length === 0) { // No-op return Promise.resolve({ success: true, patchModel: (oldModel, copy, _modeluri) => (copy ? deepClone(oldModel) : oldModel), patch: [] }); } } return this.process( this.restClient.patch(ModelServerPaths.MODEL_CRUD, encodeRequestBody(format)(patchMessage), { params: { modeluri: modeluri.toString(), format: format } }), MessageDataMapper.patchModel ); } undo(modeluri: URI): Promise<ModelUpdateResult> { return this.process( this.restClient.get(ModelServerPaths.UNDO, { params: { modeluri: modeluri.toString() } }), MessageDataMapper.patchModel ); } redo(modeluri: URI): Promise<ModelUpdateResult> { return this.process( this.restClient.get(ModelServerPaths.REDO, { params: { modeluri: modeluri.toString() } }), MessageDataMapper.patchModel ); } send(modeluri: URI, message: ModelServerMessage): void { const openSocket = this.openSockets.get(modeluri.toString()); if (openSocket) { openSocket.send(JSON.stringify(message)); } } subscribe(modeluri: URI, listener: SubscriptionListener, options: SubscriptionOptions = {}): SubscriptionListener { if (this.isSocketOpen(modeluri)) { const errorMsg = `${modeluri.toString()} : Cannot open new socket, already subscribed!'`; console.warn(errorMsg); if (options.errorWhenUnsuccessful) { throw new Error(errorMsg); } } const path = this.createSubscriptionPath(modeluri, options); this.doSubscribe(listener, modeluri, path); return listener; } unsubscribe(modeluri: URI): void { const openSocket = this.openSockets.get(modeluri.toString()); if (openSocket) { openSocket.close(); this.openSockets.delete(modeluri.toString()); } } protected createSubscriptionPath(modeluri: URI, options: SubscriptionOptions): URI { const { ...paramOptions } = options; const subscriptionUri = this._baseUrl.clone(); subscriptionUri.protocol('ws'); subscriptionUri.segment(ModelServerPaths.SUBSCRIPTION); subscriptionUri.addQuery('modeluri', modeluri); subscriptionUri.addQuery('format', options.format || this.defaultFormat); Object.entries(paramOptions).forEach(entry => subscriptionUri.addQuery(entry[0], entry[1])); subscriptionUri.removeQuery('errorWhenUnsuccessful'); return subscriptionUri; } protected doSubscribe(listener: SubscriptionListener, modeluri: URI, path: URI): void { const socket = new WebSocket(path.toString() /* .trim() */); socket.onopen = event => listener.onOpen?.(modeluri, event); socket.onclose = event => listener.onClose?.(modeluri, event); socket.onerror = event => listener.onError?.(modeluri, event); socket.onmessage = event => listener.onMessage?.(modeluri, event); this.openSockets.set(modeluri.toString(), socket); } protected isSocketOpen(modeluri: URI): boolean { return this.openSockets.get(modeluri.toString()) !== undefined; } protected async process<T>(request: Promise<AxiosResponse<ModelServerMessage>>, mapper: MessageDataMapper<T>): Promise<T> { try { const response = await request; if (response.data.type === 'error') { throw new ModelServerError(response.data); } return mapper(response.data); } catch (error) { if (axios.isAxiosError(error)) { const message = error.response?.data ? error.response.data : error.message; throw new ModelServerError(message, error.code); } else { throw error; } } } } /** * Helper type for method overloads where on parameter could either be * a 'format' string or a typeguard to cast the response to a concrete type. */ type FormatOrGuard<M> = Format | TypeGuard<M>; function mapModel<M>(model: Model, guard?: TypeGuard<M>, toString = false): Model<AnyObject | M | string> { const { modeluri, content } = model; if (guard) { return { modeluri, content: asType(content, guard) }; } else if (toString) { return { modeluri, content: asString(content) }; } return { modeluri, content: asObject(content) }; }