Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(NODE-4855): add hex and base64 ctor methods to Binary and ObjectId #569

Merged
merged 11 commits into from
Apr 4, 2023
20 changes: 18 additions & 2 deletions src/binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ export class Binary extends BSONValue {
);
}

/** Creates an Binary instance from a hex digit string */
static createFromHexString(hex: string, subType?: number): Binary {
return new Binary(ByteUtils.fromHex(hex), subType);
}

/** Creates an Binary instance from a base64 string */
static createFromBase64(base64: string, subType?: number): Binary {
return new Binary(ByteUtils.fromBase64(base64), subType);
}

/** @internal */
static fromExtendedJSON(
doc: BinaryExtendedLegacy | BinaryExtended | UUIDExtended,
Expand Down Expand Up @@ -292,7 +302,8 @@ export class Binary extends BSONValue {
}

inspect(): string {
return `new Binary(Buffer.from("${ByteUtils.toHex(this.buffer)}", "hex"), ${this.sub_type})`;
const base64 = ByteUtils.toBase64(this.buffer.subarray(0, this.position));
return `Binary.createFromBase64("${base64}", ${this.sub_type})`;
}
}

Expand Down Expand Up @@ -464,11 +475,16 @@ export class UUID extends Binary {
* Creates an UUID from a hex string representation of an UUID.
* @param hexString - 32 or 36 character hex string (dashes excluded/included).
*/
static createFromHexString(hexString: string): UUID {
static override createFromHexString(hexString: string): UUID {
const buffer = uuidHexStringToBuffer(hexString);
return new UUID(buffer);
}

/** Creates an UUID from a base64 string representation of an UUID. */
static override createFromBase64(base64: string): UUID {
return new UUID(ByteUtils.fromBase64(base64));
}

/**
* Converts to a string representation of this Id.
*
Expand Down
16 changes: 11 additions & 5 deletions src/objectid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,16 +264,22 @@ export class ObjectId extends BSONValue {
* @param hexString - create a ObjectId from a passed in 24 character hexstring.
*/
static createFromHexString(hexString: string): ObjectId {
// Throw an error if it's not a valid setup
if (typeof hexString === 'undefined' || (hexString != null && hexString.length !== 24)) {
throw new BSONError(
'Argument passed in must be a single String of 12 bytes or a string of 24 hex characters'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a copy pasta mistake, the if stmt asserts for 24 characters, so a 12 byte string wouldn't work here.

);
if (hexString?.length !== 24) {
throw new BSONError('hex string must be 24 characters');
}

return new ObjectId(ByteUtils.fromHex(hexString));
}

/** Creates an ObjectId instance from a base64 string */
static createFromBase64(base64: string): ObjectId {
if (base64?.length !== 16) {
throw new BSONError('base64 string must be 16 characters');
}

return new ObjectId(ByteUtils.fromBase64(base64));
}

/**
* Checks if a value is a valid bson ObjectId
*
Expand Down
144 changes: 144 additions & 0 deletions test/node/binary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { expect } from 'chai';
import * as vm from 'node:vm';
import { Binary, BSON } from '../register-bson';

describe('class Binary', () => {
context('constructor()', () => {
it('creates an 256 byte Binary with subtype 0 by default', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just testing the existing behavior here.

const binary = new Binary();
expect(binary).to.have.property('buffer');
expect(binary).to.have.property('position', 0);
expect(binary).to.have.property('sub_type', 0);
expect(binary).to.have.nested.property('buffer.byteLength', 256);
const emptyZeroedArray = new Uint8Array(256);
emptyZeroedArray.fill(0x00);
expect(binary.buffer).to.deep.equal(emptyZeroedArray);
});
});

context('createFromHexString()', () => {
context('when called with a hex sequence', () => {
it('returns a Binary instance with the decoded bytes', () => {
const bytes = Buffer.from('abc', 'utf8');
const binary = Binary.createFromHexString(bytes.toString('hex'));
expect(binary).to.have.deep.property('buffer', bytes);
expect(binary).to.have.property('sub_type', 0);
});

it('returns a Binary instance with the decoded bytes and subtype', () => {
const bytes = Buffer.from('abc', 'utf8');
const binary = Binary.createFromHexString(bytes.toString('hex'), 0x23);
expect(binary).to.have.deep.property('buffer', bytes);
expect(binary).to.have.property('sub_type', 0x23);
});
});

context('when called with an empty string', () => {
it('creates an empty binary', () => {
const binary = Binary.createFromHexString('');
expect(binary).to.have.deep.property('buffer', new Uint8Array(0));
expect(binary).to.have.property('sub_type', 0);
});

it('creates an empty binary with subtype', () => {
const binary = Binary.createFromHexString('', 0x42);
expect(binary).to.have.deep.property('buffer', new Uint8Array(0));
expect(binary).to.have.property('sub_type', 0x42);
});
});
});

context('createFromBase64()', () => {
context('when called with a base64 sequence', () => {
it('returns a Binary instance with the decoded bytes', () => {
const bytes = Buffer.from('abc', 'utf8');
const binary = Binary.createFromBase64(bytes.toString('base64'));
expect(binary).to.have.deep.property('buffer', bytes);
expect(binary).to.have.property('sub_type', 0);
});

it('returns a Binary instance with the decoded bytes and subtype', () => {
const bytes = Buffer.from('abc', 'utf8');
const binary = Binary.createFromBase64(bytes.toString('base64'), 0x23);
expect(binary).to.have.deep.property('buffer', bytes);
expect(binary).to.have.property('sub_type', 0x23);
});
});

context('when called with an empty string', () => {
it('creates an empty binary', () => {
const binary = Binary.createFromBase64('');
expect(binary).to.have.deep.property('buffer', new Uint8Array(0));
expect(binary).to.have.property('sub_type', 0);
});

it('creates an empty binary with subtype', () => {
const binary = Binary.createFromBase64('', 0x42);
expect(binary).to.have.deep.property('buffer', new Uint8Array(0));
expect(binary).to.have.property('sub_type', 0x42);
});
});
});

context('inspect()', () => {
it('when value is default returns "Binary.createFromBase64("", 0)"', () => {
expect(new Binary().inspect()).to.equal('Binary.createFromBase64("", 0)');
});

it('when value is empty returns "Binary.createFromBase64("", 0)"', () => {
expect(new Binary(new Uint8Array(0)).inspect()).to.equal('Binary.createFromBase64("", 0)');
});

it('when value is default with a subtype returns "Binary.createFromBase64("", 35)"', () => {
expect(new Binary(null, 0x23).inspect()).to.equal('Binary.createFromBase64("", 35)');
});

it('when value is empty with a subtype returns "Binary.createFromBase64("", 35)"', () => {
expect(new Binary(new Uint8Array(0), 0x23).inspect()).to.equal(
'Binary.createFromBase64("", 35)'
);
});

it('when value has utf8 "abcdef" encoded returns "Binary.createFromBase64("YWJjZGVm", 0)"', () => {
expect(new Binary(Buffer.from('abcdef', 'utf8')).inspect()).to.equal(
'Binary.createFromBase64("YWJjZGVm", 0)'
);
});

context('when result is executed', () => {
it('has a position of zero when constructed with default space', () => {
const bsonValue = new Binary();
const ctx = { ...BSON, module: { exports: { result: null } } };
vm.runInNewContext(`module.exports.result = ${bsonValue.inspect()}`, ctx);
expect(ctx.module.exports.result).to.have.property('position', 0);
expect(ctx.module.exports.result).to.have.property('sub_type', 0);

// While the default Binary has 256 bytes the newly constructed one will have 0
// both will have a position of zero so when serialized to BSON they are the equivalent.
expect(ctx.module.exports.result).to.have.nested.property('buffer.byteLength', 0);
expect(bsonValue).to.have.nested.property('buffer.byteLength', 256);
});

it('is deep equal with a Binary that has no data', () => {
const bsonValue = new Binary(new Uint8Array(0));
const ctx = { ...BSON, module: { exports: { result: null } } };
vm.runInNewContext(`module.exports.result = ${bsonValue.inspect()}`, ctx);
expect(ctx.module.exports.result).to.deep.equal(bsonValue);
});

it('is deep equal with a Binary that has a subtype but no data', () => {
const bsonValue = new Binary(new Uint8Array(0), 0x23);
const ctx = { ...BSON, module: { exports: { result: null } } };
vm.runInNewContext(`module.exports.result = ${bsonValue.inspect()}`, ctx);
expect(ctx.module.exports.result).to.deep.equal(bsonValue);
});

it('is deep equal with a Binary that has data', () => {
const bsonValue = new Binary(Buffer.from('abc', 'utf8'));
const ctx = { ...BSON, module: { exports: { result: null } } };
vm.runInNewContext(`module.exports.result = ${bsonValue.inspect()}`, ctx);
expect(ctx.module.exports.result).to.deep.equal(bsonValue);
});
});
});
});
4 changes: 1 addition & 3 deletions test/node/bson_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1829,9 +1829,7 @@ describe('BSON', function () {
*/
it('Binary', function () {
const binary = new Binary(Buffer.from('0123456789abcdef0123456789abcdef', 'hex'), 4);
expect(inspect(binary)).to.equal(
'new Binary(Buffer.from("0123456789abcdef0123456789abcdef", "hex"), 4)'
);
expect(inspect(binary)).to.equal('Binary.createFromBase64("ASNFZ4mrze8BI0VniavN7w==", 4)');
});

/**
Expand Down
19 changes: 18 additions & 1 deletion test/node/bson_type_classes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
UUID,
BSONValue
} from '../register-bson';
import * as vm from 'node:vm';

const BSONTypeClasses = [
Binary,
Expand All @@ -36,7 +37,7 @@ const BSONTypeClasses = [
];

const BSONTypeClassCtors = new Map<string, () => BSONValue>([
['Binary', () => new Binary()],
['Binary', () => new Binary(new Uint8Array(0), 0)],
['Code', () => new Code('function () {}')],
['DBRef', () => new DBRef('name', new ObjectId('00'.repeat(12)))],
['Decimal128', () => new Decimal128('1.23')],
Expand Down Expand Up @@ -97,4 +98,20 @@ describe('BSON Type classes common interfaces', () => {
.that.is.a('function'));
});
}

context(`when inspect() is called`, () => {
for (const [name, factory] of BSONTypeClassCtors) {
it(`${name} returns string that is runnable and has deep equality`, () => {
const bsonValue = factory();
// All BSON types should only need exactly their constructor available on the global
const ctx = { [name]: bsonValue.constructor, module: { exports: { result: null } } };
if (name === 'DBRef') {
// DBRef is the only type that requires another BSON type
ctx.ObjectId = ObjectId;
}
vm.runInNewContext(`module.exports.result = ${bsonValue.inspect()}`, ctx);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will throw if we mistakenly introduce a syntax error or if we produce strings that when invoked lead to runtime errors

expect(ctx.module.exports.result).to.deep.equal(bsonValue);
});
}
});
});
48 changes: 39 additions & 9 deletions test/node/object_id_tests.js → test/node/object_id.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
'use strict';

const Buffer = require('buffer').Buffer;
const { BSON, BSONError, EJSON, ObjectId } = require('../register-bson');
const util = require('util');
const { expect } = require('chai');
const { bufferFromHexArray } = require('./tools/utils');
const getSymbolFrom = require('./tools/utils').getSymbolFrom;
const isBufferOrUint8Array = require('./tools/utils').isBufferOrUint8Array;
import { Buffer } from 'buffer';
import { BSON, BSONError, EJSON, ObjectId } from '../register-bson';
import * as util from 'util';
import { expect } from 'chai';
import { bufferFromHexArray } from './tools/utils';
import { getSymbolFrom } from './tools/utils';
import { isBufferOrUint8Array } from './tools/utils';

describe('ObjectId', function () {
describe('static createFromTime()', () => {
Expand Down Expand Up @@ -477,4 +475,36 @@ describe('ObjectId', function () {
// class method equality
expect(Buffer.prototype.equals.call(inBuffer, outBuffer.id)).to.be.true;
});

context('createFromHexString()', () => {
context('when called with a hex sequence', () => {
it('returns a ObjectId instance with the decoded bytes', () => {
const bytes = Buffer.from('0'.repeat(24), 'hex');
const binary = ObjectId.createFromHexString(bytes.toString('hex'));
expect(binary).to.have.deep.property('id', bytes);
});
});

context('when called with an incorrect length string', () => {
it('throws an error indicating the expected length of 24', () => {
expect(() => ObjectId.createFromHexString('')).to.throw(/24/);
});
});
});

context('createFromBase64()', () => {
context('when called with a base64 sequence', () => {
it('returns a ObjectId instance with the decoded bytes', () => {
const bytes = Buffer.from('A'.repeat(16), 'base64');
const binary = ObjectId.createFromBase64(bytes.toString('base64'));
expect(binary).to.have.deep.property('id', bytes);
});
});

context('when called with an incorrect length string', () => {
it('throws an error indicating the expected length of 16', () => {
expect(() => ObjectId.createFromBase64('')).to.throw(/16/);
});
});
});
});
53 changes: 45 additions & 8 deletions test/node/uuid_tests.js → test/node/uuid.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
'use strict';

const { Buffer } = require('buffer');
const { Binary, UUID } = require('../register-bson');
const { inspect } = require('util');
const { validate: uuidStringValidate, version: uuidStringVersion } = require('uuid');
const { BSON, BSONError } = require('../register-bson');
import { Binary, UUID } from '../register-bson';
import { inspect } from 'util';
import { validate as uuidStringValidate, version as uuidStringVersion } from 'uuid';
import { BSON, BSONError } from '../register-bson';
const BSON_DATA_BINARY = BSON.BSONType.binData;
const { BSON_BINARY_SUBTYPE_UUID_NEW } = require('../../src/constants');
import { BSON_BINARY_SUBTYPE_UUID_NEW } from '../../src/constants';
import { expect } from 'chai';

// Test values
const UPPERCASE_DASH_SEPARATED_UUID_STRING = 'AAAAAAAA-AAAA-4AAA-AAAA-AAAAAAAAAAAA';
Expand Down Expand Up @@ -202,4 +200,43 @@ describe('UUID', () => {
expect(deserializedUUID).to.deep.equal(expectedResult);
});
});

context('createFromHexString()', () => {
context('when called with a hex sequence', () => {
it('returns a UUID instance with the decoded bytes', () => {
const bytes = Buffer.from(UPPERCASE_VALUES_ONLY_UUID_STRING, 'hex');

const uuidDashed = UUID.createFromHexString(UPPERCASE_DASH_SEPARATED_UUID_STRING);
expect(uuidDashed).to.have.deep.property('buffer', bytes);
expect(uuidDashed).to.be.instanceOf(UUID);

const uuidNoDashed = UUID.createFromHexString(UPPERCASE_VALUES_ONLY_UUID_STRING);
expect(uuidNoDashed).to.have.deep.property('buffer', bytes);
expect(uuidNoDashed).to.be.instanceOf(UUID);
});
});

context('when called with an incorrect length string', () => {
it('throws an error indicating the expected length of 32 or 36 characters', () => {
expect(() => UUID.createFromHexString('')).to.throw(/32 or 36 character/);
});
});
});

context('createFromBase64()', () => {
context('when called with a base64 sequence', () => {
it('returns a UUID instance with the decoded bytes', () => {
const bytes = Buffer.from(UPPERCASE_VALUES_ONLY_UUID_STRING, 'hex');
const uuid = UUID.createFromBase64(bytes.toString('base64'));
expect(uuid).to.have.deep.property('buffer', bytes);
expect(uuid).to.be.instanceOf(UUID);
});
});

context('when called with an incorrect length string', () => {
it('throws an error indicating the expected length of 16 byte Buffer', () => {
expect(() => UUID.createFromBase64('')).to.throw(/16 byte Buffer/);
});
});
});
});