From 06cabfa52e936fb939aa97d84a77e0a1888d496b Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Fri, 22 Mar 2019 17:25:46 +0100 Subject: [PATCH] fix(kernel): make type serialization explicit and recursive Serialize and deserialize types according to their declared static type, and add validation on the runtime types matching the declared types. This is in contrast to previously, when we mostly only used the runtime types to determine what to do, and harly any validation was done. The runtime types used to be able to freely disagree with the declared types, and we put a lot of burden on the JSII runtimes at the other end of the wire. Fix tests that used to exercise the API with invalid arguments. Fixes awslabs/aws-cdk#1981. --- packages/jsii-calc/lib/compliance.ts | 42 ++- packages/jsii-kernel/lib/api.ts | 10 +- packages/jsii-kernel/lib/kernel.ts | 406 +++++++++----------- packages/jsii-kernel/lib/objects.ts | 153 ++++++++ packages/jsii-kernel/lib/types.ts | 462 +++++++++++++++++++++++ packages/jsii-kernel/test/test.kernel.ts | 75 +++- 6 files changed, 904 insertions(+), 244 deletions(-) create mode 100644 packages/jsii-kernel/lib/objects.ts create mode 100644 packages/jsii-kernel/lib/types.ts diff --git a/packages/jsii-calc/lib/compliance.ts b/packages/jsii-calc/lib/compliance.ts index 1d80389d71..d16c86ccfb 100644 --- a/packages/jsii-calc/lib/compliance.ts +++ b/packages/jsii-calc/lib/compliance.ts @@ -194,6 +194,21 @@ export class AllTypes { enumMethod(value: StringEnum) { return value; } + + + public anyOut(): any { + const ret = new Number(42); + Object.defineProperty(ret, 'tag', { + value: "you're it" + }); + return ret; + } + + public anyIn(inp: any) { + if (inp.tag !== "you're it") { + throw new Error('Not the same object that I gave you!'); + } + } } // @@ -1327,12 +1342,35 @@ export class InbetweenClass extends PublicClass {} class PrivateClass extends InbetweenClass implements IPublicInterface { public bye(): void {} } + +class HiddenClass implements IPublicInterface { + public bye(): void { } +} + +class HiddenSubclass extends HiddenClass { +} + export class Constructors { public static makeClass(): PublicClass { - return new PrivateClass(); + return new PrivateClass(); // Wire type should be InbetweenClass } public static makeInterface(): IPublicInterface { - return new PrivateClass(); + return new PrivateClass(); // Wire type should be InbetweenClass + } + public static makeInterfaces(): IPublicInterface[] { + return [new PrivateClass()]; // Wire type should be InbetweenClass[] + } + + public static hiddenInterface(): IPublicInterface { + return new HiddenClass(); // Wire type should be IPublicInterface + } + + public static hiddenInterfaces(): IPublicInterface[] { + return [new HiddenClass()]; // Wire type should be IPublicInterface[] + } + + public static hiddenSubInterfaces(): IPublicInterface[] { + return [new HiddenSubclass()]; // Wire type should be IPublicInterface[] } } diff --git a/packages/jsii-kernel/lib/api.ts b/packages/jsii-kernel/lib/api.ts index 71a9c50d9c..1dabf1d314 100644 --- a/packages/jsii-kernel/lib/api.ts +++ b/packages/jsii-kernel/lib/api.ts @@ -3,7 +3,15 @@ export const TOKEN_DATE = '$jsii.date'; export const TOKEN_ENUM = '$jsii.enum'; export class ObjRef { - [token: string]: string; // token = TOKEN_REF + public [TOKEN_REF]: string; +} + +export class WireDate { + public [TOKEN_DATE]: string; +} + +export class WireEnum { + public [TOKEN_ENUM]: string; } export interface Override { diff --git a/packages/jsii-kernel/lib/kernel.ts b/packages/jsii-kernel/lib/kernel.ts index 15e615aac4..5f8569a419 100644 --- a/packages/jsii-kernel/lib/kernel.ts +++ b/packages/jsii-kernel/lib/kernel.ts @@ -7,6 +7,8 @@ import * as tar from 'tar'; import * as vm from 'vm'; import * as api from './api'; import { TOKEN_DATE, TOKEN_ENUM, TOKEN_REF } from './api'; +import { jsiiFqn, setJsiiFqn } from './objects'; +import { CompleteTypeReference, serializationType, SerializerHost, SERIALIZERS } from './types'; /** * Added to objects and contains the objid (the object reference). @@ -200,7 +202,7 @@ export class Kernel { const prototype = this._findSymbol(fqn); this._ensureSync(`property ${property}`, () => - this._wrapSandboxCode(() => prototype[property] = this._toSandbox(value))); + this._wrapSandboxCode(() => prototype[property] = this._toSandbox(value, ti.type))); return {}; } @@ -244,7 +246,7 @@ export class Kernel { const propertyToSet = this._findPropertyTarget(obj, property); this._ensureSync(`property '${objref[TOKEN_REF]}.${propertyToSet}'`, - () => this._wrapSandboxCode(() => obj[propertyToSet] = this._toSandbox(value))); + () => this._wrapSandboxCode(() => obj[propertyToSet] = this._toSandbox(value, propInfo.type))); return { }; } @@ -262,10 +264,10 @@ export class Kernel { } const ret = this._ensureSync(`method '${objref[TOKEN_REF]}.${method}'`, () => { - return this._wrapSandboxCode(() => fn.apply(obj, this._toSandboxValues(args))); + return this._wrapSandboxCode(() => fn.apply(obj, this._toSandboxValues(args, parameterTypes(ti.parameters)))); }); - return { result: this._fromSandbox(ret, ti.returns) }; + return { result: this._fromSandbox(ret, ti.returns || 'void') }; } public sinvoke(req: api.StaticInvokeRequest): api.InvokeResponse { @@ -289,11 +291,11 @@ export class Kernel { const fn = prototype[method]; const ret = this._ensureSync(`method '${fqn}.${method}'`, () => { - return this._wrapSandboxCode(() => fn.apply(null, this._toSandboxValues(args))); + return this._wrapSandboxCode(() => fn.apply(null, this._toSandboxValues(args, parameterTypes(ti.parameters)))); }); this._debug('method returned:', ret); - return { result: this._fromSandbox(ret, ti.returns) }; + return { result: this._fromSandbox(ret, ti.returns || 'void') }; } public begin(req: api.BeginRequest): api.BeginResponse { @@ -314,7 +316,7 @@ export class Kernel { throw new Error(`Method ${method} is expected to be an async method`); } - const promise = this._wrapSandboxCode(() => fn.apply(obj, this._toSandboxValues(args))) as Promise; + const promise = this._wrapSandboxCode(() => fn.apply(obj, this._toSandboxValues(args, parameterTypes(ti.parameters)))) as Promise; // since we are planning to resolve this promise in a different scope // we need to handle rejections here [1] @@ -349,7 +351,7 @@ export class Kernel { throw mapSource(e, this.sourceMaps); } - return { result: this._fromSandbox(result, method.returns) }; + return { result: this._fromSandbox(result, method.returns || 'void') }; } public callbacks(_req?: api.CallbacksRequest): api.CallbacksResponse { @@ -388,7 +390,7 @@ export class Kernel { this._debug('completed with error:', err); cb.fail(new Error(err)); } else { - const sandoxResult = this._toSandbox(result); + const sandoxResult = this._toSandbox(result, cb.expectedReturnType || 'void'); this._debug('completed with result:', sandoxResult); cb.succeed(sandoxResult); } @@ -435,20 +437,15 @@ export class Kernel { case spec.TypeKind.Class: case spec.TypeKind.Enum: const constructor = this._findSymbol(fqn); - Object.defineProperty(constructor, '__jsii__', { - configurable: false, - enumerable: false, - writable: false, - value: { fqn } - }); + setJsiiFqn(constructor, fqn); } } } // find the javascript constructor function for a jsii FQN. - private _findCtor(fqn: string, args: any[]) { + private _findCtor(fqn: string, args: any[]): { ctor: any, parameters?: spec.Parameter[] } { if (fqn === EMPTY_OBJECT_FQN) { - return Object; + return { ctor: Object }; } const typeinfo = this._typeInfoForFqn(fqn); @@ -457,7 +454,7 @@ export class Kernel { case spec.TypeKind.Class: const classType = typeinfo as spec.ClassType; this._validateMethodArguments(classType.initializer, args); - return this._findSymbol(fqn); + return { ctor: this._findSymbol(fqn), parameters: classType.initializer && classType.initializer.parameters }; case spec.TypeKind.Interface: throw new Error(`Cannot create an object with an FQN of an interface: ${fqn}`); @@ -474,8 +471,9 @@ export class Kernel { const requestArgs = req.args || []; - const ctor = this._findCtor(fqn, requestArgs); - const obj = this._wrapSandboxCode(() => new ctor(...this._toSandboxValues(requestArgs))); + const ctorResult = this._findCtor(fqn, requestArgs); + const ctor = ctorResult.ctor; + const obj = this._wrapSandboxCode(() => new ctor(...this._toSandboxValues(requestArgs, parameterTypes(ctorResult.parameters)))); const objref = this._createObjref(obj, fqn); // overrides: for each one of the override method names, installs a @@ -546,6 +544,11 @@ export class Kernel { return; } + if (!propInfo) { + this._warning('Behavior is different now, skipping private property', propertyName); + return; + } + this._debug('apply override', propertyName); // save the old property under $jsii$super$$ so that property overrides @@ -574,14 +577,14 @@ export class Kernel { get: { objref, property: propertyName } }); this._debug('callback returned', result); - return this._toSandbox(result); + return this._toSandbox(result, propInfo.type); }, set: (value: any) => { self._debug('virtual set', objref, propertyName, { cookie: override.cookie }); self.callbackHandler({ cookie: override.cookie, cbid: self._makecbid(), - set: { objref, property: propertyName, value: self._fromSandbox(value) } + set: { objref, property: propertyName, value: self._fromSandbox(value, 'void') } }); } }); @@ -598,6 +601,11 @@ export class Kernel { return; } + if (!methodInfo) { + this._warning(`Behavior is different than before, not overriding ${methodName}`); + return; + } + // note that we are applying the override even if the method doesn't exist // on the type spec in order to allow native code to override methods from // interfaces. @@ -610,7 +618,7 @@ export class Kernel { writable: false, value: (...methodArgs: any[]) => { self._debug('invoked async override', override); - const args = self._toSandboxValues(methodArgs); + const args = self._toSandboxValues(methodArgs, parameterTypes(methodInfo.parameters)); return new Promise((succeed, fail) => { const cbid = self._makecbid(); self._debug('adding callback to queue', cbid); @@ -618,6 +626,7 @@ export class Kernel { objref, override, args, + expectedReturnType: methodInfo.returns || 'void', succeed, fail }; @@ -631,16 +640,19 @@ export class Kernel { configurable: false, writable: false, value: (...methodArgs: any[]) => { + // We should be validating the actual arguments according to the + // declared parameters here, but let's just assume the JSII runtime on the + // other end has done its work. const result = self.callbackHandler({ cookie: override.cookie, cbid: self._makecbid(), invoke: { objref, method: methodName, - args: this._fromSandbox(methodArgs) + args: this._fromSandboxValues(methodArgs, parameterTypes(methodInfo.parameters)), } }); - return this._toSandbox(result); + return this._toSandbox(result, methodInfo.returns || 'void'); } }); } @@ -876,9 +888,30 @@ export class Kernel { return typeInfo; } - private _toSandbox(v: any): any { + private _toSandbox(v: any, expectedType: CompleteTypeReference): any { + const serTypes = serializationType(expectedType, this._typeInfoForFqn.bind(this)); + + const host: SerializerHost = { + debug: this._debug.bind(this), + findSymbol: this._findSymbol.bind(this), + lookupType: this._typeInfoForFqn.bind(this), + recurse: this._toSandbox.bind(this), + }; + + const errors = new Array(); + for (const serType of serTypes) { + try { + return SERIALIZERS[serType].deserialize(v, expectedType, host); + } catch (e) { + errors.push(e.message); + } + } + + throw new Error(errors.join(',')); + + // undefined - if (typeof v === 'undefined') { + if (typeof v === 'undefined' || expectedType === 'void') { return undefined; } @@ -923,14 +956,21 @@ export class Kernel { // array if (Array.isArray(v)) { - return v.map(x => this._toSandbox(x)); + if (!spec.isCollectionTypeReference(expectedType) || expectedType.collection.kind !== spec.CollectionKind.Array) { + throw new Error(`Value is array but expected type is ${JSON.stringify(expectedType)}`); + } + return v.map(x => this._toSandbox(x, expectedType.collection.elementtype)); } // map if (typeof v === 'object') { + const serializer = this._serializeAsObjectLiteral(expectedType); + if (!serializer) { + throw new Error(`Received map value (${JSON.stringify(v)}), but expected type is ${JSON.stringify(expectedType)}`); + } const out: any = { }; for (const k of Object.keys(v)) { - const value = this._toSandbox(v[k]); + const value = this._toSandbox(v[k], serializer.fieldType(k)); // javascript has a fun behavior where // { ...{ x: 'hello' }, ...{ x: undefined } } @@ -951,57 +991,26 @@ export class Kernel { return v; } - private _fromSandbox(v: any, targetType?: spec.TypeReference): any { + private _fromSandbox(v: any, targetType: spec.TypeReference | Void): any { this._debug('fromSandbox', v, targetType); - - // undefined is returned as null: true - if (typeof(v) === 'undefined') { - return undefined; + if (targetType === undefined) { + throw new Error('Expected a targetType, but did not get one'); } - if (v === null) { - return undefined; - } + // This method does hardly any validation on actual types are even convertible + // to advertised types. We're leaving this to JSII runtimes. - // existing object - const objid = v[OBJID_PROP]; - if (objid) { - // object already has an objid, return it as a ref. - this._debug('objref exists', objid); - return { [TOKEN_REF]: objid }; - } + // undefined is returned as null: true + if (typeof(v) === 'undefined' || v === null) { return undefined; } - // new object - if (typeof(v) === 'object' && v.constructor.__jsii__) { - // this is jsii object which was created inside the sandbox and still doesn't - // have an object id, so we need to allocate one for it. - this._debug('creating objref for', v); - const fqn = this._fqnForObject(v); - if (!targetType || !spec.isNamedTypeReference(targetType) || this._isAssignable(fqn, targetType)) { - return this._createObjref(v, fqn); - } + // If the expected type is Void we also just return undefined + if (targetType === 'void') { + this._warning('Value ignored because we were expecting void:', v); + return undefined; } - // if the method/property returns an object literal and the return type - // is a class, we create a new object based on the fqn and assign all keys. - // so the client receives a real object. - if (typeof(v) === 'object' && targetType && spec.isNamedTypeReference(targetType)) { - this._debug('coalescing to', targetType); - /* - * We "cache" proxy instances in [PROXIES_PROP] so we can return an - * identical object reference upon multiple accesses of the same - * object literal under the same exposed type. This results in a - * behavior that is more consistent with class instances. - */ - const proxies: Proxies = v[PROXIES_PROP] = v[PROXIES_PROP] || {}; - if (!proxies[targetType.fqn]) { - const handler = new KernelProxyHandler(v); - const proxy = new Proxy(v, handler); - // _createObjref will set the FQN_PROP & OBJID_PROP on the proxy. - proxies[targetType.fqn] = { objRef: this._createObjref(proxy, targetType.fqn), handler }; - } - return proxies[targetType.fqn].objRef; - } + const namedTypeRef = spec.isNamedTypeReference(targetType) ? targetType : undefined; + const namedType = namedTypeRef && this._typeInfoForFqn(namedTypeRef.fqn); // date (https://stackoverflow.com/a/643827/737957) if (typeof(v) === 'object' && Object.prototype.toString.call(v) === '[object Date]') { @@ -1012,70 +1021,97 @@ export class Kernel { // array if (Array.isArray(v)) { this._debug('array', v); - return v.map(x => this._fromSandbox(x)); - } - - if (targetType && spec.isNamedTypeReference(targetType)) { - const propType = this._typeInfoForFqn(targetType.fqn); - - // enum - if (propType.kind === spec.TypeKind.Enum) { - this._debug('enum', v); - const fqn = propType.fqn; - - const valueName = this._findSymbol(fqn)[v]; - - return { [TOKEN_ENUM]: `${propType.fqn}/${valueName}` }; + if (!spec.isCollectionTypeReference(targetType) || targetType.collection.kind !== spec.CollectionKind.Array) { + throw new Error(`Value is array but expected type is ${JSON.stringify(targetType)}`); } + return v.map(x => this._fromSandbox(x, targetType.collection.elementtype)); + } + // enum (primitive that is converted to an enum tag) + if (namedType && namedType.kind === spec.TypeKind.Enum) { + this._debug('enum', v); + return { [TOKEN_ENUM]: `${namedType.fqn}/${this._findSymbol(namedType.fqn)[v]}` }; } - // map - if (typeof(v) === 'object') { + // primitive + if (typeof v !== 'object' || isAny(targetType)) { + this._debug('primitive', v); + return v; + } + + // At this point our TypeScript object IS either: + // + // - instance of a declared class + // - an object literal + // + // And we EXPECT either: + // + // - a by-reference class instance; OR + // - a by-value Map + + // map (serialize by-value) + const serializer = this._serializeAsObjectLiteral(targetType); + if (serializer) { this._debug('map', v); const out: any = { }; - for (const k of Object.keys(v)) { - const value = this._fromSandbox(v[k]); - if (value === undefined) { + for (const [key, value] of Object.entries(v)) { + const wireValue = this._fromSandbox(value, serializer.fieldType(key)); + if (wireValue === undefined) { continue; } - out[k] = value; + out[key] = wireValue; } return out; } - // primitive - this._debug('primitive', v); - return v; - } + // Otherwise by-reference + if (!namedTypeRef) { + throw new Error(`Have an object (${JSON.stringify(v)}) but expecting non-object (${JSON.stringify(targetType)})`); + } - /** - * Tests whether a given type (by it's FQN) can be assigned to a named type reference. - * - * @param actualTypeFqn the FQN of the type that is being tested. - * @param requiredType the required reference type. - * - * @returns true if ``requiredType`` is a super-type (base class or implemented interface) of the type designated by - * ``actualTypeFqn``. - */ - private _isAssignable(actualTypeFqn: string, requiredType: spec.NamedTypeReference): boolean { - if (requiredType.fqn === actualTypeFqn) { - return true; - } - const actualType = this._typeInfoForFqn(actualTypeFqn); - if (spec.isClassType(actualType) && actualType.base) { - if (this._isAssignable(actualType.base.fqn, requiredType)) { - return true; - } + // existing object (return reference) + const objid = v[OBJID_PROP]; + if (objid) { + // object already has an objid, return it as a ref. + this._debug('objref exists', objid); + return { [TOKEN_REF]: objid }; } - if (spec.isClassOrInterfaceType(actualType) && actualType.interfaces) { - return actualType.interfaces.find(iface => this._isAssignable(iface.fqn, requiredType)) != null; + + const actualType = isExportedClassInstance(v) ? this._fqnForObject(v) : undefined; + const wireType = this._appropriateWireType(actualType, namedTypeRef); + + // JSII is expecting an instance of a declared type here + + if (isExportedClassInstance(v)) { + this._debug('creating objref for', v); + return this._createObjref(v, wireType); } - return false; + + this._debug('coalescing to', targetType); + + /* + * We "cache" proxy instances in [PROXIES_PROP] so we can return an + * identical object reference upon multiple accesses of the same + * object literal under the same exposed type. This results in a + * behavior that is more consistent with class instances. + */ + const proxies: Proxies = v[PROXIES_PROP] = v[PROXIES_PROP] || {}; + if (!proxies[wireType]) { + const handler = new KernelProxyHandler(v); + const proxy = new Proxy(v, handler); + // _createObjref will set the FQN_PROP & OBJID_PROP on the proxy. + proxies[wireType] = { objRef: this._createObjref(proxy, wireType), handler }; + } + return proxies[wireType].objRef; + } + + + private _toSandboxValues(xs: any[], types: spec.TypeReference[]) { + return xs.map((x, i) => this._toSandbox(x, types[i])); } - private _toSandboxValues(args: any[]) { - return args.map(v => this._toSandbox(v)); + private _fromSandboxValues(xs: any[], types: spec.TypeReference[]) { + return xs.map((x, i) => this._fromSandbox(x, types[i])); } private _debug(...args: any[]) { @@ -1083,12 +1119,20 @@ export class Kernel { // tslint:disable-next-line:no-console console.error.apply(console, [ '[jsii-kernel]', - args[0], - ...args.slice(1) + ...args ]); } } + private _warning(...args: any[]) { + // tslint:disable-next-line:no-console + console.error.apply(console, [ + '[jsii-kernel]', + args[0], + ...args.slice(1) + ]); + } + /** * Ensures that `fn` is called and defends against beginning to invoke * async methods until fn finishes (successfully or not). @@ -1117,16 +1161,14 @@ export class Kernel { // type information // - private _fqnForObject(obj: any) { - if (FQN_PROP in obj) { - return obj[FQN_PROP]; - } + private _fqnForObject(obj: any): string { + const fqn = jsiiFqn(obj); - if (!obj.constructor.__jsii__) { + if (!fqn) { throw new Error('No jsii type info for object'); } - return obj.constructor.__jsii__.fqn; + return fqn; } private _mkobjid(fqn: string) { @@ -1173,6 +1215,7 @@ interface Callback { objref: api.ObjRef; override: api.Override; args: any[]; + expectedReturnType: CompleteTypeReference; // completion callbacks succeed: (...args: any[]) => any; @@ -1237,110 +1280,9 @@ function mapSource(err: Error, sourceMaps: { [assm: string]: SourceMapConsumer } } } -type ObjectKey = string | number | symbol; /** - * A Proxy handler class to support mutation of the returned object literals, as - * they may "embody" several different interfaces. The handler is in particular - * responsible to make sure the ``FQN_PROP`` and ``OBJID_PROP`` do not get set - * on the ``referent`` object, for this would cause subsequent accesses to - * possibly return incorrect object references. + * Get a list of types from a list of parameters */ -class KernelProxyHandler implements ProxyHandler { - private readonly ownProperties: { [key: string]: any } = {}; - - /** - * @param referent the "real" value that will be returned. - */ - constructor(public readonly referent: any) { - /* - * Proxy-properties must exist as non-configurable & writable on the - * referent, otherwise the Proxy will not allow returning ``true`` in - * response to ``defineProperty``. - */ - for (const prop of [FQN_PROP, OBJID_PROP]) { - Object.defineProperty(referent, prop, { - configurable: false, - enumerable: false, - writable: true, - value: undefined - }); - } - } - - public defineProperty(target: any, property: ObjectKey, attributes: PropertyDescriptor): boolean { - switch (property) { - case FQN_PROP: - case OBJID_PROP: - return Object.defineProperty(this.ownProperties, property, attributes); - default: - return Object.defineProperty(target, property, attributes); - } - } - - public deleteProperty(target: any, property: ObjectKey): boolean { - switch (property) { - case FQN_PROP: - case OBJID_PROP: - delete this.ownProperties[property]; - break; - default: - delete target[property]; - } - return true; - } - - public getOwnPropertyDescriptor(target: any, property: ObjectKey): PropertyDescriptor | undefined { - switch (property) { - case FQN_PROP: - case OBJID_PROP: - return Object.getOwnPropertyDescriptor(this.ownProperties, property); - default: - return Object.getOwnPropertyDescriptor(target, property); - } - } - - public get(target: any, property: ObjectKey): any { - switch (property) { - // Magical property for the proxy, so we can tell it's one... - case PROXY_REFERENT_PROP: - return this.referent; - case FQN_PROP: - case OBJID_PROP: - return this.ownProperties[property]; - default: - return target[property]; - } - } - - public set(target: any, property: ObjectKey, value: any): boolean { - switch (property) { - case FQN_PROP: - case OBJID_PROP: - this.ownProperties[property] = value; - break; - default: - target[property] = value; - } - return true; - } - - public has(target: any, property: ObjectKey): boolean { - switch (property) { - case FQN_PROP: - case OBJID_PROP: - return property in this.ownProperties; - default: - return property in target; - } - } - - public ownKeys(target: any): ObjectKey[] { - return Reflect.ownKeys(target).concat(Reflect.ownKeys(this.ownProperties)); - } -} - -type Proxies = { [fqn: string]: ProxyReference }; -interface ProxyReference { - objRef: api.ObjRef; - handler: KernelProxyHandler; +function parameterTypes(parameters?: spec.Parameter[]): spec.TypeReference[] { + return (parameters || []).map(p => p.type); } diff --git a/packages/jsii-kernel/lib/objects.ts b/packages/jsii-kernel/lib/objects.ts new file mode 100644 index 0000000000..30eef97e80 --- /dev/null +++ b/packages/jsii-kernel/lib/objects.ts @@ -0,0 +1,153 @@ +import { ObjRef } from "./api"; + +/** + * Added to objects and contains the objid (the object reference). + * Used to find the object id from an object. + */ +export const OBJID_PROP = '$__jsii__objid__$'; +export const FQN_PROP = '$__jsii__fqn__$'; +export const PROXIES_PROP = '$__jsii__proxies__$'; +export const PROXY_REFERENT_PROP = '$__jsii__proxy_referent__$'; + +type ObjectKey = string | number | symbol; + +export function jsiiFqn(obj: any): string | undefined { + if (FQN_PROP in obj) { + return obj[FQN_PROP]; + } + + return obj.constructor.__jsii__.fqn; +} + +export function setJsiiFqn(constructor: any, fqn: string) { + Object.defineProperty(constructor, '__jsii__', { + configurable: false, + enumerable: false, + writable: false, + value: { fqn } + }); +} + +export function createProxy(obj: any, typeName: string): ObjRef { + /* + * We "cache" proxy instances in [PROXIES_PROP] so we can return an + * identical object reference upon multiple accesses of the same + * object literal under the same exposed type. This results in a + * behavior that is more consistent with class instances. + */ + const proxies: Proxies = obj[PROXIES_PROP] = obj[PROXIES_PROP] || {}; + if (!proxies[typeName]) { + const handler = new KernelProxyHandler(obj); + const proxy = new Proxy(obj, handler); + // _createObjref will set the FQN_PROP & OBJID_PROP on the proxy. + proxies[typeName] = { objRef: this._createObjref(proxy, typeName), handler }; + } + return proxies[typeName].objRef; +} + +/** + * A Proxy handler class to support mutation of the returned object literals, as + * they may "embody" several different interfaces. The handler is in particular + * responsible to make sure the ``FQN_PROP`` and ``OBJID_PROP`` do not get set + * on the ``referent`` object, for this would cause subsequent accesses to + * possibly return incorrect object references. + */ +export class KernelProxyHandler implements ProxyHandler { + private readonly ownProperties: { [key: string]: any } = {}; + + /** + * @param referent the "real" value that will be returned. + */ + constructor(public readonly referent: any) { + /* + * Proxy-properties must exist as non-configurable & writable on the + * referent, otherwise the Proxy will not allow returning ``true`` in + * response to ``defineProperty``. + */ + for (const prop of [FQN_PROP, OBJID_PROP]) { + Object.defineProperty(referent, prop, { + configurable: false, + enumerable: false, + writable: true, + value: undefined + }); + } + } + + public defineProperty(target: any, property: ObjectKey, attributes: PropertyDescriptor): boolean { + switch (property) { + case FQN_PROP: + case OBJID_PROP: + return Object.defineProperty(this.ownProperties, property, attributes); + default: + return Object.defineProperty(target, property, attributes); + } + } + + public deleteProperty(target: any, property: ObjectKey): boolean { + switch (property) { + case FQN_PROP: + case OBJID_PROP: + delete this.ownProperties[property]; + break; + default: + delete target[property]; + } + return true; + } + + public getOwnPropertyDescriptor(target: any, property: ObjectKey): PropertyDescriptor | undefined { + switch (property) { + case FQN_PROP: + case OBJID_PROP: + return Object.getOwnPropertyDescriptor(this.ownProperties, property); + default: + return Object.getOwnPropertyDescriptor(target, property); + } + } + + public get(target: any, property: ObjectKey): any { + switch (property) { + // Magical property for the proxy, so we can tell it's one... + case PROXY_REFERENT_PROP: + return this.referent; + case FQN_PROP: + case OBJID_PROP: + return this.ownProperties[property]; + default: + return target[property]; + } + } + + public set(target: any, property: ObjectKey, value: any): boolean { + switch (property) { + case FQN_PROP: + case OBJID_PROP: + this.ownProperties[property] = value; + break; + default: + target[property] = value; + } + return true; + } + + public has(target: any, property: ObjectKey): boolean { + switch (property) { + case FQN_PROP: + case OBJID_PROP: + return property in this.ownProperties; + default: + return property in target; + } + } + + public ownKeys(target: any): ObjectKey[] { + return Reflect.ownKeys(target).concat(Reflect.ownKeys(this.ownProperties)); + } +} + +type Proxies = { [fqn: string]: ProxyReference }; +interface ProxyReference { + objRef: api.ObjRef; + handler: KernelProxyHandler; +} diff --git a/packages/jsii-kernel/lib/types.ts b/packages/jsii-kernel/lib/types.ts new file mode 100644 index 0000000000..73f99555f5 --- /dev/null +++ b/packages/jsii-kernel/lib/types.ts @@ -0,0 +1,462 @@ + + // tslint:disable:max-line-length +/** + * Handling of types in JSII + * + * Types will be serialized according to the following table: + * + * ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ + * │ DECLARED TYPE │ + * ┌───────────────────┼───────────┬──────────────┬──────────────┬─────────────────┬──────────────┬──────────────┬──────────────┬────────────────┬─────────────────┬────────────────┬────────────────┤ + * │ JS Type │ void │ date │ scalar (*) │ json │ enum │ array of T │ map of T │ interface │ struct │ class │ any │ + * ├───────────────────┼───────────┼──────────────┼──────────────┼─────────────────┼──────────────┼──────────────┼──────────────┼────────────────┼─────────────────┼────────────────┼────────────────┤ + * │ undefined/null │ undefined │ undefined(†) │ undefined(†) │ undefined │ undefined(†) │ undefined(†) │ undefined(†) │ undefined(†) │ undefined(†) │ undefined(†) │ undefined │ + * │ date │ undefined │ { date } │ - │ string │ - │ - │ - │ - │ - │ - │ { date } │ + * │ scalar (*) │ undefined │ - │ value │ value │ { enum } │ - │ - │ - │ - │ - │ value │ + * │ array │ undefined │ - │ - │ array/R(json) │ - │ array/R(T) │ - │ - │ - │ - │ array/R(any) │ + * │ JSII-class object │ undefined │ - │ - │ byvalue/R(json) │ - │ - │ - │ { ref } │ - │ { ref } │ { ref } │ + * │ literal object │ undefined │ - │ - │ byvalue/R(json) │ - │ - │ byvalue/R(T) │ { ref: proxy } │ byvalue/R(T[k]) │ { ref: proxy } │ byvalue/R(any) │ + * └───────────────────┴───────────┴──────────────┴──────────────┴─────────────────┴──────────────┴──────────────┴──────────────┴────────────────┴─────────────────┴────────────────┴────────────────┘ + * + * - (*) scalar means 'string | number | boolean' + * - (†) throw if not nullable + * - /R(t) recurse with declared type t + */ + + // tslint:enable:max-line-length + +import * as spec from 'jsii-spec'; +import { ObjRef, TOKEN_DATE, TOKEN_ENUM, TOKEN_REF, WireDate, WireEnum } from './api'; +import { jsiiFqn, OBJID_PROP, createProxy } from './objects'; + +/** + * A specific singleton type to be explicit about a Void type + * + * In the spec, 'void' is represented as 'undefined', but allowing the value + * 'undefined' in function calls has lead to consumers failing to pass type information + * that they had, just because they didn't "have to". + */ +export type Void = 'void'; + +export type CompleteTypeReference = spec.TypeReference | Void; + +/** + * The type kind, that controls how it will be serialized according to the above table + */ +export const enum SerializationType { + Void = 'Void', + Date = 'Date', + Scalar = 'Scalar', + Json = 'Json', + Enum = 'Enum', + Array = 'Array', + Map = 'Map', + Struct = 'Struct', + ReferenceType = 'RefType', + Any = 'Any', +} + +type TypeLookup = (fqn: string) => spec.Type; + +export interface SerializerHost { + debug(...args: any[]): void; + lookupType(fqn: string): spec.Type; + recurse(x: any, type: CompleteTypeReference): any; + findSymbol(fqn: string): any; +} + +interface Serializer { + serialize(value: unknown, type: CompleteTypeReference, host: SerializerHost): any; + deserialize(value: unknown, type: CompleteTypeReference, host: SerializerHost): any; +} + +export const SERIALIZERS: {[k: string]: Serializer} = { + // ---------------------------------------------------------------------- + [SerializationType.Void]: { + serialize(value, _type, host) { + if (value != null) { + host.debug('Expecting void from runtime but got', value); + } + return undefined; + }, + + deserialize(value, _type, host) { + if (value != null) { + host.debug('Expecting void from wire but got', value); + } + return undefined; + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.Date]: { + serialize(value, type): WireDate | undefined { + if (nullAndOk(value, type)) { return undefined; } + + if (objectKind(value) !== RuntimeKind.Date) { + throw new Error(`Expected Date from runtime but got ${JSON.stringify(value)}`); + } + return { [TOKEN_DATE]: (value as Date).toISOString() }; + }, + + deserialize(value, type) { + if (nullAndOk(value, type)) { return undefined; } + + if (typeof value !== 'object' || value == null || !(TOKEN_DATE in value)) { + throw new Error(`Expecting Date from wire but got ${JSON.stringify(value)}`); + } + return new Date((value as any)[TOKEN_DATE]); + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.Scalar]: { + serialize(value, type) { + if (nullAndOk(value, type)) { return undefined; } + + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + throw new Error(`Expected Scalar from runtime but got ${JSON.stringify(value)}`); + } + return value; + }, + + deserialize(value, type) { + if (nullAndOk(value, type)) { return undefined; } + + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + throw new Error(`Expected Scalar from runtime but got ${JSON.stringify(value)}`); + } + + return value; + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.Json]: { + serialize(value) { + // Just whatever. Dates will automatically serialize themselves to strings. + return value; + }, + deserialize(value) { + return value; + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.Enum]: { + serialize(value, type, host): WireEnum | undefined { + if (nullAndOk(value, type)) { return undefined; } + + if (typeof value !== 'string' || typeof value !== 'number') { + throw new Error(`Expected enum value from runtime, got ${JSON.stringify(value)}`); + } + const enumType = type as spec.EnumType; + return { [TOKEN_ENUM]: `${enumType.fqn}/${host.findSymbol(enumType.fqn)[value]}` }; + }, + deserialize(value, type, host) { + if (nullAndOk(value, type)) { return undefined; } + + if (typeof value !== 'object' || value == null || !(TOKEN_ENUM in value)) { + throw new Error(`Expected enum value from wire, got ${JSON.stringify(value)}`); + } + + const enumLocator = (value as WireEnum)[TOKEN_ENUM] as string; + const sep = enumLocator.lastIndexOf('/'); + if (sep === -1) { + throw new Error(`Malformed enum value: ${JSON.stringify(value)}`); + } + + const typeName = enumLocator.substr(0, sep); + const valueName = enumLocator.substr(sep + 1); + + const enumValue = host.findSymbol(typeName)[valueName]; + if (enumValue === undefined) { + throw new Error(`No enum member named ${valueName} in ${typeName}`); + } + + host.debug('resolved enum value:', enumValue); + return enumValue; + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.Array]: { + serialize(value, type, host) { + if (nullAndOk(value, type)) { return undefined; } + + if (!Array.isArray(value)) { + throw new Error(`Expected array type from runtime but got ${JSON.stringify(value)}`); + } + + const arrayType = type as spec.CollectionTypeReference; + + return value.map(x => host.recurse(x, arrayType.collection.elementtype)); + }, + deserialize(value, type, host) { + if (nullAndOk(value, type)) { return undefined; } + + if (!Array.isArray(value)) { + throw new Error(`Expected array type from runtime but got ${JSON.stringify(value)}`); + } + + const arrayType = type as spec.CollectionTypeReference; + + return value.map(x => host.recurse(x, arrayType.collection.elementtype)); + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.Map]: { + serialize(value, type, host) { + if (nullAndOk(value, type)) { return undefined; } + + const mapType = type as spec.CollectionTypeReference; + return mapValues(value, v => host.recurse(v, mapType.collection.elementtype)); + }, + deserialize(value, type, host) { + if (nullAndOk(value, type)) { return undefined; } + + const mapType = type as spec.CollectionTypeReference; + return mapValues(value, v => host.recurse(v, mapType.collection.elementtype)); + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.Struct]: { + serialize(value, type, host) { + if (nullAndOk(value, type)) { return undefined; } + + const namedType = host.lookupType((type as spec.NamedTypeReference).fqn); + const props = propertiesOf(namedType); + + return mapValues(value, (v, key) => { + if (!props[key]) { return undefined; } // Don't map if unknown property + return host.recurse(v, props[key].type); + }); + }, + deserialize(value, type, host) { + if (nullAndOk(value, type)) { return undefined; } + + const namedType = host.lookupType((type as spec.NamedTypeReference).fqn); + const props = propertiesOf(namedType); + + return mapValues(value, (v, key) => { + if (!props[key]) { return undefined; } // Don't map if unknown property + return host.recurse(v, props[key].type); + }); + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.ReferenceType]: { + serialize(value, type, host): ObjRef | undefined { + if (nullAndOk(value, type)) { return undefined; } + + if (typeof value !== 'object' || value == null) { + throw new Error(`Expecting object but got ${JSON.stringify(value)}`); + } + + const objid = (value as any)[OBJID_PROP]; + if (objid) { + host.debug('objref exists', objid); + return { [TOKEN_REF]: objid }; + } + + const jsiiType = jsiiFqn(value); + + const namedTypeRef = type as spec.NamedTypeReference; + + if (jsiiType) { + const wireType = pickWireType(jsiiType, namedTypeRef, host.lookupType); + host.debug('creating objref for', value); + return this._createObjref(value, wireType); + } + + // Object literal + host.debug('coalescing to', type); + return createProxy(value, namedTypeRef.fqn); + }, + deserialize(value, type, host) { + if (nullAndOk(value, type)) { return undefined; } + + // pointer + if (typeof value !== 'object' || value == null || !(TOKEN_REF in value)) { + throw new Error(`Expecting object but got ${JSON.stringify(value)}`); + } + + return this._findObject(value); + }, + }, + + // ---------------------------------------------------------------------- + [SerializationType.Any]: { + serialize(value) { + if (value == null) { return undefined; } + + throw new Error(`Oops ${value}`); + }, + deserialize(value) { + if (value == null) { return undefined; } + + throw new Error(`Oops ${value}`); + }, + }, +}; + +/** + * From a type reference, return the possible serialization types + * + * There can be multiple, because the type can be a type union + */ +export function serializationType(x: CompleteTypeReference, lookup: TypeLookup): SerializationType[] { + if (x === 'void') { return [SerializationType.Void]; } + if (spec.isPrimitiveTypeReference(x)) { + switch (x.primitive) { + case spec.PrimitiveType.Any: return [SerializationType.Any]; + case spec.PrimitiveType.Date: return [SerializationType.Date]; + case spec.PrimitiveType.Json: return [SerializationType.Json]; + case spec.PrimitiveType.Boolean: + case spec.PrimitiveType.Number: + case spec.PrimitiveType.String: + return [SerializationType.Scalar]; + } + + throw new Error('Unknown primitive type'); + } + if (spec.isCollectionTypeReference(x)) { + return x.collection.kind === spec.CollectionKind.Array ? [SerializationType.Array] : [SerializationType.Map]; + } + if (spec.isUnionTypeReference(x)) { + return flatMap(x.union.types, t => serializationType(t, lookup)); + } + + // The next part of the conversion is lookup-dependent + const type = lookup(x.fqn); + + if (spec.isEnumType(type)) { + return [SerializationType.Enum]; + } + + if (spec.isInterfaceType(type) && type.datatype) { + return [SerializationType.Struct]; + } + + return [SerializationType.ReferenceType]; +} + +function nullAndOk(x: unknown, type: CompleteTypeReference): boolean { + if (x != null) { return false; } + + if (type !== 'void' && !type.optional) { + throw new Error(`Got 'undefined' for non-nullable type ${JSON.stringify(type)}`); + } + + return true; +} + +export enum RuntimeKind { + Undefined, + Date, + Scalar, + Array, + JsiiObject, + ObjectLiteral, +} + +export function objectKind(x: unknown): RuntimeKind { + if (typeof x === 'undefined') { return RuntimeKind.Undefined; } + // date (https://stackoverflow.com/a/643827/737957) + if (typeof x === 'object' && Object.prototype.toString.call(x) === '[object Date]') { + return RuntimeKind.Date; + } + if (Array.isArray(x)) { return RuntimeKind.Array; } + if (typeof x !== 'object') { return RuntimeKind.Scalar; } + if (x === null) { return RuntimeKind.Undefined; } + + if ((x.constructor as any).__jsii__) { + return RuntimeKind.JsiiObject; + } else { + return RuntimeKind.ObjectLiteral; + } +} + +function flatMap(xs: T[], fn: (x: T) => U[]): U[] { + const ret = new Array(); + for (const x of xs) { ret.push(...fn(x)); } + return ret; +} + +/** + * Map an object's values, skipping 'undefined' values' + */ +function mapValues(value: unknown, fn: (value: any, field: string) => any) { + if (typeof value !== 'object' || value == null) { + throw new Error(`Expected object type but got ${JSON.stringify(value)}`); + } + + const out: any = { }; + for (const [k, v] of Object.entries(value)) { + const wireValue = fn(v, k); + if (wireValue === undefined) { continue; } + out[k] = wireValue; + } + return out; +} + +function propertiesOf(t: spec.Type): {[name: string]: spec.Property} { + if (!spec.isClassOrInterfaceType(t)) { return {}; } + + const ret: {[name: string]: spec.Property} = {}; + for (const prop of t.properties || []) { + ret[prop.name] = prop; + } + return ret; +} + +/** + * Return the wire type for the given type + * + * Should return the most specific type that is in the JSII assembly and + * assignable to the required type. + * + * We actually don't need to search much; because of prototypal constructor + * linking, object.constructor.__jsii__ will have the FQN of the most specific + * exported JSII class this object is an instance of. + * + * Either that's assignable to the requested type, in which case we return it, + * or it's not, in which case there's a hidden class that implements the interface + * and we just return the interface so the other side can instantiate an interface + * proxy for it. + */ +function pickWireType(actualTypeFqn: string | undefined, expectedType: spec.NamedTypeReference, lookup: TypeLookup): string { + if (actualTypeFqn && isAssignable(actualTypeFqn, expectedType, lookup)) { + return actualTypeFqn; + } else { + return expectedType.fqn; + } +} + +/** + * Tests whether a given type (by it's FQN) can be assigned to a named type reference. + * + * @param actualTypeFqn the FQN of the type that is being tested. + * @param requiredType the required reference type. + * + * @returns true if ``requiredType`` is a super-type (base class or implemented interface) of the type designated by + * ``actualTypeFqn``. + */ +function isAssignable(actualTypeFqn: string, requiredType: spec.NamedTypeReference, lookup: TypeLookup): boolean { + if (requiredType.fqn === actualTypeFqn) { + return true; + } + const actualType = lookup(actualTypeFqn); + if (spec.isClassType(actualType) && actualType.base) { + if (isAssignable(actualType.base.fqn, requiredType, lookup)) { + return true; + } + } + if (spec.isClassOrInterfaceType(actualType) && actualType.interfaces) { + return actualType.interfaces.find(iface => isAssignable(iface.fqn, requiredType, lookup)) != null; + } + return false; +} \ No newline at end of file diff --git a/packages/jsii-kernel/test/test.kernel.ts b/packages/jsii-kernel/test/test.kernel.ts index 730911a589..d75f74d254 100644 --- a/packages/jsii-kernel/test/test.kernel.ts +++ b/packages/jsii-kernel/test/test.kernel.ts @@ -18,6 +18,9 @@ const calcVersion = require('jsii-calc/package.json').version.replace(/\+.+$/, ' // tslint:disable:no-console // tslint:disable:max-line-length +// Do this so that regexes stringify nicely in approximate tests +(RegExp.prototype as any).toJSON = function() { return this.source; }; + process.setMaxListeners(9999); // since every kernel instance adds an `on('exit')` handler. process.on('unhandledRejection', e => { @@ -124,9 +127,14 @@ defineTest('in/out primitive types', async (test, sandbox) => { sandbox.set({ objref: alltypes, property: 'numberProperty', value: 123 }); test.deepEqual(sandbox.get({ objref: alltypes, property: 'numberProperty' }).value, 123); + // in -> out for an ANY const num = sandbox.create({ fqn: '@scope/jsii-calc-lib.Number', args: [ 444 ] }); sandbox.set({ objref: alltypes, property: 'anyProperty', value: num }); test.deepEqual(sandbox.get({ objref: alltypes, property: 'anyProperty' }).value, num); + + // out -> in for an ANY + const ret = sandbox.invoke({ objref: alltypes, method: 'anyOut' }).result; + sandbox.invoke({ objref: alltypes, method: 'anyIn', args: [ret] }); }); defineTest('in/out objects', async (test, sandbox) => { @@ -144,7 +152,15 @@ defineTest('in/out collections', async (test, sandbox) => { sandbox.set({ objref: alltypes, property: 'arrayProperty', value: array }); test.deepEqual(sandbox.get({ objref: alltypes, property: 'arrayProperty' }).value, array); - const map = { a: 12, b: 33, c: 33, d: { e: 123 }}; + const num = create(sandbox, '@scope/jsii-calc-lib.Number'); + + const map = { + a: num(12), + b: num(33), + c: num(33), + d: num(123), + }; + sandbox.set({ objref: alltypes, property: 'mapProperty', value: map }); test.deepEqual(sandbox.get({ objref: alltypes, property: 'mapProperty' }).value, map); }); @@ -305,8 +321,8 @@ defineTest('type-checking: argument count in methods and initializers', async (t test.throws(() => sandbox.invoke({ objref: obj, method: 'methodWithOptionalArguments', args: [] }), /Not enough arguments/); test.throws(() => sandbox.invoke({ objref: obj, method: 'methodWithOptionalArguments', args: [ 1 ]}), /Not enough arguments/); sandbox.invoke({ objref: obj, method: 'methodWithOptionalArguments', args: [ 1, 'hello' ] }); - sandbox.invoke({ objref: obj, method: 'methodWithOptionalArguments', args: [ 1, 'hello', new Date() ] }); - test.throws(() => sandbox.invoke({ objref: obj, method: 'methodWithOptionalArguments', args: [ 1, 'hello', new Date(), 'too much' ] }), /Too many arguments/); + sandbox.invoke({ objref: obj, method: 'methodWithOptionalArguments', args: [ 1, 'hello', { [api.TOKEN_DATE]: new Date().toISOString() } ]}); + test.throws(() => sandbox.invoke({ objref: obj, method: 'methodWithOptionalArguments', args: [ 1, 'hello', { [api.TOKEN_DATE]: new Date().toISOString() }, 'too much' ] }), /Too many arguments/); }); defineTest('verify object literals are converted to real classes', async (test, sandbox) => { @@ -958,13 +974,18 @@ defineTest('JSII_AGENT is undefined in node.js', async (test, sandbox) => { }); defineTest('ObjRefs are labeled with the "most correct" type', async (test, sandbox) => { - const classRef = sandbox.sinvoke({ fqn: 'jsii-calc.Constructors', method: 'makeClass' }).result as api.ObjRef; - const ifaceRef = sandbox.sinvoke({ fqn: 'jsii-calc.Constructors', method: 'makeInterface' }).result as api.ObjRef; + typeMatches('makeClass', { '$jsii.byref': /^jsii-calc.InbetweenClass@/ }); + typeMatches('makeInterface', { '$jsii.byref': /^jsii-calc.InbetweenClass@/ }); + typeMatches('makeInterfaces', [ { '$jsii.byref': /^jsii-calc.InbetweenClass@/ } ]); + typeMatches('hiddenInterface', { '$jsii.byref': /^jsii-calc.IPublicInterface@/ }); + typeMatches('hiddenInterfaces', [ { '$jsii.byref': /^jsii-calc.IPublicInterface@/ } ]); + typeMatches('hiddenSubInterfaces', [ { '$jsii.byref': /^jsii-calc.IPublicInterface@/ } ]); + + function typeMatches(staticMethod: string, typeSpec: any) { + const ret = sandbox.sinvoke({ fqn: 'jsii-calc.Constructors', method: staticMethod }).result as api.ObjRef; - test.ok(classRef[api.TOKEN_REF].startsWith('jsii-calc.InbetweenClass'), - `${classRef[api.TOKEN_REF]} starts with jsii-calc.InbetweenClass`); - test.ok(ifaceRef[api.TOKEN_REF].startsWith('jsii-calc.IPublicInterface'), - `${ifaceRef[api.TOKEN_REF]} starts with jsii-calc.IPublicInterface`); + test.ok(deepEqualWithRegex(ret, typeSpec), `Constructors.${staticMethod}() => ${JSON.stringify(ret)}, does not match ${JSON.stringify(typeSpec)}`); + } }); defineTest('toSandbox: "null" in hash values send to JS should be treated as non-existing key', async (test, sandbox) => { @@ -1083,3 +1104,39 @@ function makeSyncCallbackHandler(logic: (callback: Callback) => any) { return result; }; } + +export function deepEqualWithRegex(lvalue: any, rvalue: any): boolean { + if (lvalue === rvalue) { return true; } + if (typeof lvalue === 'string' && rvalue instanceof RegExp) { return rvalue.test(lvalue); } + if (typeof lvalue !== typeof rvalue) { return false; } + if (Array.isArray(lvalue) !== Array.isArray(rvalue)) { return false; } + if (Array.isArray(lvalue) /* && Array.isArray(rvalue) */) { + if (lvalue.length !== rvalue.length) { return false; } + for (let i = 0 ; i < lvalue.length ; i++) { + if (!deepEqualWithRegex(lvalue[i], rvalue[i])) { return false; } + } + return true; + } + if (typeof lvalue === 'object' /* && typeof rvalue === 'object' */) { + if (lvalue === null || rvalue === null) { + // If both were null, they'd have been === + return false; + } + const keys = Object.keys(lvalue); + if (keys.length !== Object.keys(rvalue).length) { return false; } + for (const key of keys) { + if (!rvalue.hasOwnProperty(key)) { return false; } + if (!deepEqualWithRegex(lvalue[key], rvalue[key])) { return false; } + } + return true; + } + // Neither object, nor array: I deduce this is primitive type + // Primitive type and not ===, so I deduce not deepEqual + return false; +} + +function create(kernel: Kernel, fqn: string) { + return (...args: any[]) => { + return kernel.create({ fqn, args }); + }; +}