diff --git a/CHANGELOG.md b/CHANGELOG.md index 86268b4..1807ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed +* Increase robustness of compatibility across multiple `js-xdr` instances in an environment ([#122](https://github.com/stellar/js-xdr/pull/122)). + ## [v3.1.1](https://github.com/stellar/js-xdr/compare/v3.1.0...v3.1.1) diff --git a/src/array.js b/src/array.js index b5b9b6e..189865f 100644 --- a/src/array.js +++ b/src/array.js @@ -25,7 +25,7 @@ export class Array extends XdrCompositeType { * @inheritDoc */ write(value, writer) { - if (!(value instanceof global.Array)) + if (!global.Array.isArray(value)) throw new XdrWriterError(`value is not array`); if (value.length !== this._length) diff --git a/src/enum.js b/src/enum.js index 7ed9d8c..ec949f1 100644 --- a/src/enum.js +++ b/src/enum.js @@ -1,5 +1,5 @@ import { Int } from './int'; -import { XdrPrimitiveType } from './xdr-type'; +import { XdrPrimitiveType, isSerializableIsh } from './xdr-type'; import { XdrReaderError, XdrWriterError } from './errors'; export class Enum extends XdrPrimitiveType { @@ -26,8 +26,13 @@ export class Enum extends XdrPrimitiveType { * @inheritDoc */ static write(value, writer) { - if (!(value instanceof this)) - throw new XdrWriterError(`unknown ${value} is not a ${this.enumName}`); + if (!this.isValid(value)) { + throw new XdrWriterError( + `${value} has enum name ${value?.enumName}, not ${ + this.enumName + }: ${JSON.stringify(value)}` + ); + } Int.write(value.value, writer); } @@ -36,7 +41,10 @@ export class Enum extends XdrPrimitiveType { * @inheritDoc */ static isValid(value) { - return value instanceof this; + return ( + value?.constructor?.enumName === this.enumName || + isSerializableIsh(value, this) + ); } static members() { diff --git a/src/struct.js b/src/struct.js index 7d0b308..93e3898 100644 --- a/src/struct.js +++ b/src/struct.js @@ -1,8 +1,8 @@ import { Reference } from './reference'; -import { XdrPrimitiveType } from './xdr-type'; +import { XdrCompositeType, isSerializableIsh } from './xdr-type'; import { XdrWriterError } from './errors'; -export class Struct extends XdrPrimitiveType { +export class Struct extends XdrCompositeType { constructor(attributes) { super(); this._attributes = attributes || {}; @@ -23,8 +23,13 @@ export class Struct extends XdrPrimitiveType { * @inheritDoc */ static write(value, writer) { - if (!(value instanceof this)) - throw new XdrWriterError(`${value} is not a ${this.structName}`); + if (!this.isValid(value)) { + throw new XdrWriterError( + `${value} has struct name ${value?.constructor?.structName}, not ${ + this.structName + }: ${JSON.stringify(value)}` + ); + } for (const [fieldName, type] of this._fields) { const attribute = value._attributes[fieldName]; @@ -36,7 +41,10 @@ export class Struct extends XdrPrimitiveType { * @inheritDoc */ static isValid(value) { - return value instanceof this; + return ( + value?.constructor?.structName === this.structName || + isSerializableIsh(value, this) + ); } static create(context, name, fields) { diff --git a/src/union.js b/src/union.js index fd64194..2523334 100644 --- a/src/union.js +++ b/src/union.js @@ -1,6 +1,6 @@ import { Void } from './void'; import { Reference } from './reference'; -import { XdrCompositeType } from './xdr-type'; +import { XdrCompositeType, isSerializableIsh } from './xdr-type'; import { XdrWriterError } from './errors'; export class Union extends XdrCompositeType { @@ -81,8 +81,13 @@ export class Union extends XdrCompositeType { * @inheritDoc */ static write(value, writer) { - if (!(value instanceof this)) - throw new XdrWriterError(`${value} is not a ${this.unionName}`); + if (!this.isValid(value)) { + throw new XdrWriterError( + `${value} has union name ${value?.unionName}, not ${ + this.unionName + }: ${JSON.stringify(value)}` + ); + } this._switchOn.write(value.switch(), writer); value.armType().write(value.value(), writer); @@ -92,7 +97,10 @@ export class Union extends XdrCompositeType { * @inheritDoc */ static isValid(value) { - return value instanceof this; + return ( + value?.constructor?.unionName === this.unionName || + isSerializableIsh(value, this) + ); } static create(context, name, config) { diff --git a/src/xdr-type.js b/src/xdr-type.js index 23b6818..8dd631d 100644 --- a/src/xdr-type.js +++ b/src/xdr-type.js @@ -170,6 +170,48 @@ function decodeInput(input, format) { } } +/** + * Provides a "duck typed" version of the native `instanceof` for read/write. + * + * "Duck typing" means if the parameter _looks like_ and _acts like_ a duck + * (i.e. the type we're checking), it will be treated as that type. + * + * In this case, the "type" we're looking for is "like XdrType" but also "like + * XdrCompositeType|XdrPrimitiveType" (i.e. serializable), but also conditioned + * on a particular subclass of "XdrType" (e.g. {@link Union} which extends + * XdrType). + * + * This makes the package resilient to downstream systems that may be combining + * many versions of a package across its stack that are technically compatible + * but fail `instanceof` checks due to cross-pollination. + */ +export function isSerializableIsh(value, subtype) { + return ( + value !== undefined && + value !== null && // prereqs, otherwise `getPrototypeOf` pops + (value instanceof subtype || // quickest check + // Do an initial constructor check (anywhere is fine so that children of + // `subtype` still work), then + (hasConstructor(value, subtype) && + // ensure it has read/write methods, then + typeof value.constructor.read === 'function' && + typeof value.constructor.write === 'function' && + // ensure XdrType is in the prototype chain + hasConstructor(value, 'XdrType'))) + ); +} + +/** Tries to find `subtype` in any of the constructors or meta of `instance`. */ +export function hasConstructor(instance, subtype) { + do { + const ctor = instance.constructor; + if (ctor.name === subtype) { + return true; + } + } while ((instance = Object.getPrototypeOf(instance))); + return false; +} + /** * @typedef {'raw'|'hex'|'base64'} XdrEncodingFormat */ diff --git a/test/unit/enum_test.js b/test/unit/enum_test.js index 3a63019..f9b59c7 100644 --- a/test/unit/enum_test.js +++ b/test/unit/enum_test.js @@ -1,5 +1,6 @@ import { XdrReader } from '../../src/serialization/xdr-reader'; import { XdrWriter } from '../../src/serialization/xdr-writer'; +import { Enum } from '../../src/enum'; /* jshint -W030 */ @@ -87,6 +88,24 @@ describe('Enum.isValid', function () { expect(Color.isValid(Color.evenMoreGreen())).to.be.true; }); + it('works for "enum-like" objects', function () { + class FakeEnum extends Enum {} + FakeEnum.enumName = 'Color'; + + let r = new FakeEnum(); + expect(Color.isValid(r)).to.be.true; + + FakeEnum.enumName = 'NotColor'; + r = new FakeEnum(); + expect(Color.isValid(r)).to.be.false; + + // make sure you can't fool it + FakeEnum.enumName = undefined; + FakeEnum.unionName = 'Color'; + r = new FakeEnum(); + expect(Color.isValid(r)).to.be.false; + }); + it('returns false for arrays of the wrong size', function () { expect(Color.isValid(null)).to.be.false; expect(Color.isValid(undefined)).to.be.false; diff --git a/test/unit/struct_test.js b/test/unit/struct_test.js index cab8c9c..ee6e50b 100644 --- a/test/unit/struct_test.js +++ b/test/unit/struct_test.js @@ -1,5 +1,6 @@ import { XdrReader } from '../../src/serialization/xdr-reader'; import { XdrWriter } from '../../src/serialization/xdr-writer'; +import { Struct } from '../../src/struct'; /* jshint -W030 */ @@ -83,6 +84,18 @@ describe('Struct.isValid', function () { expect(MyRange.isValid(new MyRange({}))).to.be.true; }); + it('works for "struct-like" objects', function () { + class FakeStruct extends Struct {} + + FakeStruct.structName = 'MyRange'; + let r = new FakeStruct(); + expect(MyRange.isValid(r)).to.be.true; + + FakeStruct.structName = 'NotMyRange'; + r = new FakeStruct(); + expect(MyRange.isValid(r)).to.be.false; + }); + it('returns false for anything else', function () { expect(MyRange.isValid(null)).to.be.false; expect(MyRange.isValid(undefined)).to.be.false; diff --git a/test/unit/union_test.js b/test/unit/union_test.js index 6132f4d..cd57f74 100644 --- a/test/unit/union_test.js +++ b/test/unit/union_test.js @@ -1,5 +1,6 @@ import { XdrReader } from '../../src/serialization/xdr-reader'; import { XdrWriter } from '../../src/serialization/xdr-writer'; +import { XdrPrimitiveType } from '../../src/xdr-type'; /* jshint -W030 */ @@ -130,6 +131,24 @@ describe('Union.isValid', function () { expect(Result.isValid(Result.nonsense())).to.be.true; }); + it('works for "union-like" objects', function () { + class FakeUnion extends XdrPrimitiveType {} + + FakeUnion.unionName = 'Result'; + let r = new FakeUnion(); + expect(Result.isValid(r)).to.be.true; + + FakeUnion.unionName = 'NotResult'; + r = new FakeUnion(); + expect(Result.isValid(r)).to.be.false; + + // make sure you can't fool it + FakeUnion.unionName = undefined; + FakeUnion.structName = 'Result'; + r = new FakeUnion(); + expect(Result.isValid(r)).to.be.false; + }); + it('returns false for anything else', function () { expect(Result.isValid(null)).to.be.false; expect(Result.isValid(undefined)).to.be.false; @@ -137,5 +156,6 @@ describe('Union.isValid', function () { expect(Result.isValid({})).to.be.false; expect(Result.isValid(1)).to.be.false; expect(Result.isValid(true)).to.be.false; + expect(Result.isValid('ok')).to.be.false; }); });