@eclipse-emfcloud/modelserver-client
Version:
Typescript rest client to interact with an EMF.cloud modelserver
376 lines (328 loc) • 12.7 kB
text/typescript
/********************************************************************************
* Copyright (c) 2021-2022 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 URI from 'urijs';
import { Format, FORMAT_JSON_V1, FORMAT_JSON_V2, FORMAT_XMI, JsonFormat } from '../model-server-client-api-v2';
import { Model } from '../model-server-message';
/**
* The built-in 'object' & 'Object' types are currently hard to use
* an should be avoided. It's recommended to use Record instead to describe the
* type meaning of "any object";
*/
export type AnyObject = Record<PropertyKey, unknown>;
export namespace AnyObject {
/**
* Type guard to check wether a given object is of type {@link AnyObject}.
* @param object The object to check.
* @returns The given object as {@link AnyObject} or `false`.
*/
export function is(object: unknown): object is AnyObject {
// eslint-disable-next-line no-null/no-null
return object !== null && typeof object === 'object';
}
}
/**
* Type that describes a type guard function for a specific type.
* Takes any object as input and verifies wether the object is of the given concrete type.
* @typeParam T the concrete type
*/
export type TypeGuard<T> = (object: unknown) => object is T;
/**
* Validates whether the given object as a property of type `string` with the given key.
* @param object The object that should be validated
* @param propertyKey The key of the property
* @returns `true` if the object has property with matching key of type `string`
*/
export function isString(object: AnyObject, propertyKey: string): boolean {
return propertyKey in object && typeof object[propertyKey] === 'string';
}
/**
* Validates whether the given object as a property of type `boolean` with the given key.
* @param object The object that should be validated
* @param propertyKey The key of the property
* @returns `true` if the object has property with matching key of type `boolean`
*/
export function isBoolean(object: AnyObject, propertyKey: string): boolean {
return propertyKey in object && typeof object[propertyKey] === 'boolean';
}
/**
* Validates whether the given object as a property of type `number` with the given key.
* @param object The object that should be validated
* @param propertyKey The key of the property
* @returns `true` if the object has property with matching key of type `number`
*/
export function isNumber(object: AnyObject, propertyKey: string): boolean {
return propertyKey in object && typeof object[propertyKey] === 'number';
}
/**
* Validates whether the given object as a property of type `object` with the given key.
* @param object The object that should be validated
* @param propertyKey The key of the property
* @returns `true` if the object has property of type {@link AnyObject}
*/
export function isObject(object: AnyObject, propertyKey: string): boolean {
return propertyKey in object && AnyObject.is(object[propertyKey]);
}
/**
* Validates whether the given object as a property of type `Array` with the given key.
* @param object The object that should be validated
* @param propertyKey The key of the property
* @returns `true` if the object has property with matching key of type `Array`
*/
export function isArray(object: AnyObject, propertyKey: string): boolean {
return propertyKey in object && Array.isArray(object[propertyKey]);
}
/**
* Maps the given object to `string`.
* @param object The object to map
* @returns The object as `string`
*/
export function asString(object: unknown): string {
if (typeof object === 'string') {
return object;
}
if (Model.is(object)) {
return Model.toString(object);
}
if (Array.isArray(object) && object.every(Model.is)) {
return object.map(e => Model.toString(e)).toString();
}
if (isURI(object)) {
return asURI(object).toString();
}
if (Array.isArray(object) && object.every(isURI)) {
const uriArray = object.map(e => asURI(e).toString());
return JSON.stringify(uriArray, undefined, 2);
}
return JSON.stringify(object, undefined, 2);
}
/**
* Maps the given object to a `string` array.
* @param object The object to map
* @returns The object as `string` array
* @throws {@link Error} if the given object is not an array
*/
export function asStringArray(object: unknown): string[] {
if (Array.isArray(object)) {
return object.map(asString);
}
throw new Error('Cannot map to string[]. Given parameter is not an array!');
}
/**
* Checks wether the given object is a defined object of type `object`.
* @param object The object to check
* @returns The correctly typed object
* @throws {@link Error} if the given object is not defined or of type 'object'.
*/
export function asObject(object: unknown): AnyObject {
if (AnyObject.is(object)) {
return object;
}
throw new Error('Cannot map to object! Given parameter is not a defined object');
}
/**
* Maps the given object to a concrete Type `T`.
* @param object The object to map
* @param guard The type guard function to test the given object against
* @returns The object as type `T`
* @throws {@link Error} if the given object fails the type guard check
*/
export function asType<T>(object: unknown, guard: TypeGuard<T>): T {
if (guard(object)) {
return object;
}
throw new Error('Cannot map to given type. Given parameter failed the type guard check!');
}
export function asModelArray(object: unknown): Model[] {
if (AnyObject.is(object)) {
return Object.entries(object).map(entry => ({ modeluri: entry[0], content: entry[1] }));
}
throw new Error('Cannot map to Model[]. The given object is no defined or of type "object"!');
}
/**
* Validates whether the given object is a (deferred) instance of an URI object.
* @param object The object that should be validated
* @returns `true` if the object is an instance of URI
*/
export function isURI(object: unknown): object is URI {
return AnyObject.is(object) && isObject(object, '_parts') && isBoolean(object, '_deferred_build') && isString(object, '_string');
}
/**
* Maps the given object to an `URI` object.
* @param obj The object to map
* @returns The object as `URI`
*/
export function asURI(obj: unknown): URI {
if (typeof obj === 'string') {
return new URI(obj);
}
// reconstruct if URI object was deferred
if (isURI(obj)) {
return new URI((obj as any)._parts);
}
throw new Error('Cannot map to URI. Given parameter is not an URI!');
}
/**
* Maps the given object to a `string` array.
* @param object The object to map
* @returns The object as `URI` array
* @throws {@link Error} if the given object is not an array
*/
export function asURIArray(object: unknown): URI[] {
if (Array.isArray(object)) {
return object.map(asURI);
}
throw new Error('Cannot map to URI[]. Given parameter is not an array!');
}
/** Protocol of a message encoder. */
export type Encoder<T = unknown> = (object: string | AnyObject) => T | string;
/**
* Obtain a message encoder for the request body.
*
* @param format the encoding format
* @returns the request body encoder format
*/
export function encodeRequestBody(format: Format): Encoder<{ data: any }> {
const encoder = encode(format);
return object => ({ data: encoder(object) });
}
/**
* Obtain a message encoder.
*
* @param format the encoding format
* @returns the encoder
*/
export function encode(format: Format): Encoder {
switch (format) {
case FORMAT_XMI:
return asXML;
case FORMAT_JSON_V1:
return handleString(asJsonV1);
case FORMAT_JSON_V2:
return handleString(asJsonV2);
default:
throw new Error(`Unsupported message format: ${format}`);
}
}
/**
* Wrap an encoder to handle string inputs. When the input is a string,
* it is parsed to convert the internal JSON structure to the appropriate
* JSON format and then unparsed back to a string for serialization.
*
* @param fn an encoder
* @returns the wrapped encoder
*/
function handleString<T>(fn: Encoder<T>): Encoder<T> {
return (target: string | AnyObject) => {
if (typeof target === 'string') {
const parsed = JSON.parse(target);
const result = fn(parsed);
return JSON.stringify(result);
}
return fn(target);
};
}
function asXML(object: string | AnyObject): string {
if (typeof object !== 'string') {
throw new Error('Attempt to encode non-string as XML');
}
return object;
}
function asJsonV1(object: AnyObject): any {
return isJsonV1(object) ? object : copy(object, FORMAT_JSON_V1);
}
function asJsonV2(object: AnyObject): any {
return isJsonV2(object) ? object : copy(object, FORMAT_JSON_V2);
}
function isJsonV1(object: AnyObject): boolean {
return findProperty(object, 'eClass');
}
function isJsonV2(object: AnyObject): boolean {
return findProperty(object, '$type');
}
function findProperty(object: any, name: string, visited = new Set()): boolean {
return traverse(object, (target, props) => (props.includes(name) ? true : undefined));
}
/**
* Copy an object graph for the sole purpose of writing over the wire in a JSON format.
*
* @param object the object graph to copy
* @param format the JSON format in which to copy it
* @returns the copied object graph
*/
function copy(object: any, format: JsonFormat): any {
// A map of original to copy
const copies: Map<any, any> = new Map();
const copier = (target: any): void => {
const theCopy = { ...target };
if (format === FORMAT_JSON_V1 && '$type' in theCopy) {
theCopy.eClass = theCopy.$type;
delete theCopy.$type;
} else if (format === FORMAT_JSON_V2 && 'eClass' in theCopy) {
theCopy.$type = theCopy.eClass;
delete theCopy.eClass;
}
copies.set(target, theCopy);
};
const referencer = (target: any, props: string[]): void => {
const copyGetter = (o: any): any => copies.get(o) ?? o;
// We want to traverse all properties of object type; already guarded via `getOwnPropertyNames`.
// eslint-disable-next-line guard-for-in
for (const prop of props) {
const value = target[prop];
if (isNonEmptyObjectArray(value)) {
target[prop] = value.map(copyGetter);
} else if (Array.isArray(value)) {
// It's an array of non-object data types
target[prop] = [...value];
} else if (typeof value === 'object') {
target[prop] = copyGetter(value);
}
}
};
// Step one: copy everything
traverse(object, copier);
const result = copies.get(object);
// Step two: then rewrite cross-references
traverse(result, referencer);
return result;
}
function traverse<T>(object: any, fn: (target: any, props: string[]) => T | undefined, visited = new Set()): boolean {
// Only traverse into objects. Null, undefined, strings, numbers, booleans are primitive values in JSON and functions don't exist.
// eslint-disable-next-line no-null/no-null
if (typeof object !== 'object' || object === null) {
// JSON uses null
return false;
}
if (visited.has(object)) {
return false;
}
visited.add(object);
const ownProps = Object.getOwnPropertyNames(object);
const result = fn(object, ownProps);
if (result !== undefined) {
return true;
}
// We want to traverse all properties of object type; already guarded via `getOwnPropertyNames`.
// eslint-disable-next-line guard-for-in
for (const prop of ownProps) {
const value = object[prop];
if (isNonEmptyObjectArray(value) && value.some(element => traverse(element, fn, visited))) {
return true;
}
if (typeof value === 'object' && traverse(value, fn, visited)) {
return true;
}
}
return false;
}
function isNonEmptyObjectArray(value: any): value is AnyObject[] {
return Array.isArray(value) && value.length > 0 && typeof value[0] === 'object' && !Array.isArray(value[0]);
}