From 6921f6dc47e83cd27fe24861843204d6fb359de9 Mon Sep 17 00:00:00 2001 From: SeungWon Date: Sun, 2 Jul 2023 01:39:41 +0900 Subject: [PATCH 1/5] Add: bson ObjectID --- src/scalars/library/bson/ObjectId.ts | 323 ++++++++++++++++++ src/scalars/library/bson/bsonValue.ts | 18 + src/scalars/library/bson/error.ts | 77 +++++ src/scalars/library/bson/index.ts | 1 + src/scalars/library/bson/parser/utils.ts | 3 + src/scalars/library/bson/utils/byteUtills.ts | 60 ++++ .../library/bson/utils/nodeByteUtils.ts | 141 ++++++++ .../library/bson/utils/webByteUtils.ts | 188 ++++++++++ 8 files changed, 811 insertions(+) create mode 100644 src/scalars/library/bson/ObjectId.ts create mode 100644 src/scalars/library/bson/bsonValue.ts create mode 100644 src/scalars/library/bson/error.ts create mode 100644 src/scalars/library/bson/index.ts create mode 100644 src/scalars/library/bson/parser/utils.ts create mode 100644 src/scalars/library/bson/utils/byteUtills.ts create mode 100644 src/scalars/library/bson/utils/nodeByteUtils.ts create mode 100644 src/scalars/library/bson/utils/webByteUtils.ts diff --git a/src/scalars/library/bson/ObjectId.ts b/src/scalars/library/bson/ObjectId.ts new file mode 100644 index 000000000..79a6d8397 --- /dev/null +++ b/src/scalars/library/bson/ObjectId.ts @@ -0,0 +1,323 @@ +import { BSONValue } from './bsonValue.js'; +import { BSONError } from './error.js'; +import { isUint8Array } from './parser/utils.js'; +import { BSONDataView, ByteUtils } from './utils/byteUtills.js'; + +const HEX_REGEX = /^[0-9a-fA-F]+$/; + +// Unique sequence for the current process (initialized on first use) +let PROCESS_UNIQUE: Uint8Array | null = null; + +/** @public */ +export interface ObjectIdLike { + id: string | Uint8Array; + __id?: string; + toHexString(): string; +} + +/** @public */ +export interface ObjectIdExtended { + $oid: string; +} + +const kId = Symbol('id'); + +/** + * A class representation of the BSON ObjectId type. + * @public + * @category BSONType + */ +export class ObjectId extends BSONValue { + get _bsontype(): 'ObjectId' { + return 'ObjectId'; + } + + /** @internal */ + private static index = Math.floor(Math.random() * 0xffffff); + + static cacheHexString: boolean; + + /** ObjectId Bytes @internal */ + private [kId]!: Uint8Array; + /** ObjectId hexString cache @internal */ + private __id?: string; + + /** + * Create an ObjectId type + * + * @param inputId - Can be a 24 character hex string, 12 byte binary Buffer, or a number. + */ + constructor(inputId?: string | number | ObjectId | ObjectIdLike | Uint8Array) { + super(); + // workingId is set based on type of input and whether valid id exists for the input + let workingId; + if (typeof inputId === 'object' && inputId && 'id' in inputId) { + if (typeof inputId.id !== 'string' && !ArrayBuffer.isView(inputId.id)) { + throw new BSONError('Argument passed in must have an id that is of type string or Buffer'); + } + if ('toHexString' in inputId && typeof inputId.toHexString === 'function') { + workingId = ByteUtils.fromHex(inputId.toHexString()); + } else { + workingId = inputId.id; + } + } else { + workingId = inputId; + } + + // the following cases use workingId to construct an ObjectId + if (workingId == null || typeof workingId === 'number') { + // The most common use case (blank id, new objectId instance) + // Generate a new id + this[kId] = ObjectId.generate(typeof workingId === 'number' ? workingId : undefined); + } else if (ArrayBuffer.isView(workingId) && workingId.byteLength === 12) { + // If intstanceof matches we can escape calling ensure buffer in Node.js environments + this[kId] = ByteUtils.toLocalBufferType(workingId); + } else if (typeof workingId === 'string') { + if (workingId.length === 12) { + // TODO(NODE-4361): Remove string of length 12 support + const bytes = ByteUtils.fromUTF8(workingId); + if (bytes.byteLength === 12) { + this[kId] = bytes; + } else { + throw new BSONError('Argument passed in must be a string of 12 bytes'); + } + } else if (workingId.length === 24 && HEX_REGEX.test(workingId)) { + this[kId] = ByteUtils.fromHex(workingId); + } else { + throw new BSONError( + 'Argument passed in must be a string of 12 bytes or a string of 24 hex characters or an integer', + ); + } + } else { + throw new BSONError('Argument passed in does not match the accepted types'); + } + // If we are caching the hex string + if (ObjectId.cacheHexString) { + this.__id = ByteUtils.toHex(this.id); + } + } + + /** + * The ObjectId bytes + * @readonly + */ + get id(): Uint8Array { + return this[kId]; + } + + set id(value: Uint8Array) { + this[kId] = value; + if (ObjectId.cacheHexString) { + this.__id = ByteUtils.toHex(value); + } + } + + /** Returns the ObjectId id as a 24 character hex string representation */ + toHexString(): string { + if (ObjectId.cacheHexString && this.__id) { + return this.__id; + } + + const hexString = ByteUtils.toHex(this.id); + + if (ObjectId.cacheHexString && !this.__id) { + this.__id = hexString; + } + + return hexString; + } + + /** + * Update the ObjectId index + * @internal + */ + private static getInc(): number { + return (ObjectId.index = (ObjectId.index + 1) % 0xffffff); + } + + /** + * Generate a 12 byte id buffer used in ObjectId's + * + * @param time - pass in a second based timestamp. + */ + static generate(time?: number): Uint8Array { + if (typeof time !== 'number') { + time = Math.floor(Date.now() / 1000); + } + + const inc = ObjectId.getInc(); + const buffer = ByteUtils.allocate(12); + + // 4-byte timestamp + BSONDataView.fromUint8Array(buffer).setUint32(0, time, false); + + // set PROCESS_UNIQUE if yet not initialized + if (PROCESS_UNIQUE === null) { + PROCESS_UNIQUE = ByteUtils.randomBytes(5); + } + + // 5-byte process unique + buffer[4] = PROCESS_UNIQUE[0]; + buffer[5] = PROCESS_UNIQUE[1]; + buffer[6] = PROCESS_UNIQUE[2]; + buffer[7] = PROCESS_UNIQUE[3]; + buffer[8] = PROCESS_UNIQUE[4]; + + // 3-byte counter + buffer[11] = inc & 0xff; + buffer[10] = (inc >> 8) & 0xff; + buffer[9] = (inc >> 16) & 0xff; + + return buffer; + } + + /** + * Converts the id into a 24 character hex string for printing, unless encoding is provided. + * @param encoding - hex or base64 + */ + toString(encoding?: 'hex' | 'base64'): string { + // Is the id a buffer then use the buffer toString method to return the format + if (encoding === 'base64') return ByteUtils.toBase64(this.id); + if (encoding === 'hex') return this.toHexString(); + return this.toHexString(); + } + + /** Converts to its JSON the 24 character hex string representation. */ + toJSON(): string { + return this.toHexString(); + } + + /** + * Compares the equality of this ObjectId with `otherID`. + * + * @param otherId - ObjectId instance to compare against. + */ + equals(otherId: string | ObjectId | ObjectIdLike): boolean { + if (otherId === undefined || otherId === null) { + return false; + } + + if (otherId instanceof ObjectId) { + return this[kId][11] === otherId[kId][11] && ByteUtils.equals(this[kId], otherId[kId]); + } + + if ( + typeof otherId === 'string' && + ObjectId.isValid(otherId) && + otherId.length === 12 && + isUint8Array(this.id) + ) { + return ByteUtils.equals(this.id, ByteUtils.fromISO88591(otherId)); + } + + if (typeof otherId === 'string' && ObjectId.isValid(otherId) && otherId.length === 24) { + return otherId.toLowerCase() === this.toHexString(); + } + + if (typeof otherId === 'string' && ObjectId.isValid(otherId) && otherId.length === 12) { + return ByteUtils.equals(ByteUtils.fromUTF8(otherId), this.id); + } + + if ( + typeof otherId === 'object' && + 'toHexString' in otherId && + typeof otherId.toHexString === 'function' + ) { + const otherIdString = otherId.toHexString(); + const thisIdString = this.toHexString().toLowerCase(); + return typeof otherIdString === 'string' && otherIdString.toLowerCase() === thisIdString; + } + + return false; + } + + /** Returns the generation date (accurate up to the second) that this ID was generated. */ + getTimestamp(): Date { + const timestamp = new Date(); + const time = BSONDataView.fromUint8Array(this.id).getUint32(0, false); + timestamp.setTime(Math.floor(time) * 1000); + return timestamp; + } + + /** @internal */ + static createPk(): ObjectId { + return new ObjectId(); + } + + /** + * Creates an ObjectId from a second based number, with the rest of the ObjectId zeroed out. Used for comparisons or sorting the ObjectId. + * + * @param time - an integer number representing a number of seconds. + */ + static createFromTime(time: number): ObjectId { + const buffer = ByteUtils.fromNumberArray([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + // Encode time into first 4 bytes + BSONDataView.fromUint8Array(buffer).setUint32(0, time, false); + // Return the new objectId + return new ObjectId(buffer); + } + + /** + * Creates an ObjectId from a hex string representation of an ObjectId. + * + * @param hexString - create a ObjectId from a passed in 24 character hexstring. + */ + static createFromHexString(hexString: string): ObjectId { + 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 + * + * @param id - ObjectId instance to validate. + */ + static isValid(id: string | number | ObjectId | ObjectIdLike | Uint8Array): boolean { + if (id == null) return false; + + try { + // eslint-disable-next-line no-new + new ObjectId(id); + return true; + } catch { + return false; + } + } + + /** @internal */ + toExtendedJSON(): ObjectIdExtended { + if (this.toHexString) return { $oid: this.toHexString() }; + return { $oid: this.toString('hex') }; + } + + /** @internal */ + static fromExtendedJSON(doc: ObjectIdExtended): ObjectId { + return new ObjectId(doc.$oid); + } + + /** + * Converts to a string representation of this Id. + * + * @returns return the 24 character hex string representation. + * @internal + */ + [Symbol.for('nodejs.util.inspect.custom')](): string { + return this.inspect(); + } + + inspect(): string { + return `new ObjectId("${this.toHexString()}")`; + } +} diff --git a/src/scalars/library/bson/bsonValue.ts b/src/scalars/library/bson/bsonValue.ts new file mode 100644 index 000000000..09cb408a7 --- /dev/null +++ b/src/scalars/library/bson/bsonValue.ts @@ -0,0 +1,18 @@ +const BSON_MAJOR_VERSION = 5 as const; + +/** @public */ +export abstract class BSONValue { + /** @public */ + public abstract get _bsontype(): string; + + /** @internal */ + get [Symbol.for('@@mdb.bson.version')](): typeof BSON_MAJOR_VERSION { + return BSON_MAJOR_VERSION; + } + + /** @public */ + public abstract inspect(): string; + + /** @internal */ + abstract toExtendedJSON(): unknown; +} diff --git a/src/scalars/library/bson/error.ts b/src/scalars/library/bson/error.ts new file mode 100644 index 000000000..9377be7d5 --- /dev/null +++ b/src/scalars/library/bson/error.ts @@ -0,0 +1,77 @@ +const BSON_MAJOR_VERSION = 5 as const; + +export class BSONError extends Error { + /** + * @internal + * The underlying algorithm for isBSONError may change to improve how strict it is + * about determining if an input is a BSONError. But it must remain backwards compatible + * with previous minors & patches of the current major version. + */ + protected get bsonError(): true { + return true; + } + + override get name(): string { + return 'BSONError'; + } + + constructor(message: string) { + super(message); + } + + /** + * @public + * + * All errors thrown from the BSON library inherit from `BSONError`. + * This method can assist with determining if an error originates from the BSON library + * even if it does not pass an `instanceof` check against this class' constructor. + * + * @param value - any javascript value that needs type checking + */ + public static isBSONError(value: unknown): value is BSONError { + return ( + value != null && + typeof value === 'object' && + 'bsonError' in value && + value.bsonError === true && + // Do not access the following properties, just check existence + 'name' in value && + 'message' in value && + 'stack' in value + ); + } +} + +/** + * @public + * @category Error + */ +export class BSONVersionError extends BSONError { + get name(): 'BSONVersionError' { + return 'BSONVersionError'; + } + + constructor() { + super( + `Unsupported BSON version, bson types must be from bson ${BSON_MAJOR_VERSION}.0 or later`, + ); + } +} + +/** + * @public + * @category Error + * + * An error generated when BSON functions encounter an unexpected input + * or reaches an unexpected/invalid internal state + * + */ +export class BSONRuntimeError extends BSONError { + get name(): 'BSONRuntimeError' { + return 'BSONRuntimeError'; + } + + constructor(message: string) { + super(message); + } +} diff --git a/src/scalars/library/bson/index.ts b/src/scalars/library/bson/index.ts new file mode 100644 index 000000000..85ff260dd --- /dev/null +++ b/src/scalars/library/bson/index.ts @@ -0,0 +1 @@ +export { ObjectId as ObjectID } from './ObjectId.js'; diff --git a/src/scalars/library/bson/parser/utils.ts b/src/scalars/library/bson/parser/utils.ts new file mode 100644 index 000000000..258896af3 --- /dev/null +++ b/src/scalars/library/bson/parser/utils.ts @@ -0,0 +1,3 @@ +export function isUint8Array(value: unknown): value is Uint8Array { + return Object.prototype.toString.call(value) === '[object Uint8Array]'; +} diff --git a/src/scalars/library/bson/utils/byteUtills.ts b/src/scalars/library/bson/utils/byteUtills.ts new file mode 100644 index 000000000..f2037a95e --- /dev/null +++ b/src/scalars/library/bson/utils/byteUtills.ts @@ -0,0 +1,60 @@ +import { nodeJsByteUtils } from './nodeByteUtils.js'; +import { webByteUtils } from './webByteUtils.js'; + +export type ByteUtils = { + /** Transforms the input to an instance of Buffer if running on node, otherwise Uint8Array */ + toLocalBufferType(buffer: Uint8Array | ArrayBufferView | ArrayBuffer): Uint8Array; + /** Create empty space of size */ + allocate: (size: number) => Uint8Array; + /** Check if two Uint8Arrays are deep equal */ + equals: (a: Uint8Array, b: Uint8Array) => boolean; + /** Check if two Uint8Arrays are deep equal */ + fromNumberArray: (array: number[]) => Uint8Array; + /** Create a Uint8Array from a base64 string */ + fromBase64: (base64: string) => Uint8Array; + /** Create a base64 string from bytes */ + toBase64: (buffer: Uint8Array) => string; + /** **Legacy** binary strings are an outdated method of data transfer. Do not add public API support for interpreting this format */ + fromISO88591: (codePoints: string) => Uint8Array; + /** **Legacy** binary strings are an outdated method of data transfer. Do not add public API support for interpreting this format */ + toISO88591: (buffer: Uint8Array) => string; + /** Create a Uint8Array from a hex string */ + fromHex: (hex: string) => Uint8Array; + /** Create a hex string from bytes */ + toHex: (buffer: Uint8Array) => string; + /** Create a Uint8Array containing utf8 code units from a string */ + fromUTF8: (text: string) => Uint8Array; + /** Create a string from utf8 code units */ + toUTF8: (buffer: Uint8Array) => string; + /** Get the utf8 code unit count from a string if it were to be transformed to utf8 */ + utf8ByteLength: (input: string) => number; + /** Encode UTF8 bytes generated from `source` string into `destination` at byteOffset. Returns the number of bytes encoded. */ + encodeUTF8Into(destination: Uint8Array, source: string, byteOffset: number): number; + /** Generate a Uint8Array filled with random bytes with byteLength */ + randomBytes(byteLength: number): Uint8Array; +}; + +declare const Buffer: { new (): unknown; prototype?: { _isBuffer?: boolean } } | undefined; + +/** + * Check that a global Buffer exists that is a function and + * does not have a '_isBuffer' property defined on the prototype + * (this is to prevent using the npm buffer) + */ +const hasGlobalBuffer = typeof Buffer === 'function' && Buffer.prototype?._isBuffer !== true; + +/** + * This is the only ByteUtils that should be used across the rest of the BSON library. + * + * The type annotation is important here, it asserts that each of the platform specific + * utils implementations are compatible with the common one. + * + * @internal + */ +export const ByteUtils: ByteUtils = hasGlobalBuffer ? nodeJsByteUtils : webByteUtils; + +export class BSONDataView extends DataView { + static fromUint8Array(input: Uint8Array) { + return new DataView(input.buffer, input.byteOffset, input.byteLength); + } +} diff --git a/src/scalars/library/bson/utils/nodeByteUtils.ts b/src/scalars/library/bson/utils/nodeByteUtils.ts new file mode 100644 index 000000000..828776596 --- /dev/null +++ b/src/scalars/library/bson/utils/nodeByteUtils.ts @@ -0,0 +1,141 @@ +import { BSONError } from '../error.js'; + +type NodeJsEncoding = 'base64' | 'hex' | 'utf8' | 'binary'; +type NodeJsBuffer = ArrayBufferView & + Uint8Array & { + write(string: string, offset: number, length: undefined, encoding: 'utf8'): number; + copy(target: Uint8Array, targetStart: number, sourceStart: number, sourceEnd: number): number; + toString: (this: Uint8Array, encoding: NodeJsEncoding) => string; + equals: (this: Uint8Array, other: Uint8Array) => boolean; + }; +type NodeJsBufferConstructor = Omit & { + alloc: (size: number) => NodeJsBuffer; + from(array: number[]): NodeJsBuffer; + from(array: Uint8Array): NodeJsBuffer; + from(array: ArrayBuffer): NodeJsBuffer; + from(array: ArrayBuffer, byteOffset: number, byteLength: number): NodeJsBuffer; + from(base64: string, encoding: NodeJsEncoding): NodeJsBuffer; + byteLength(input: string, encoding: 'utf8'): number; + isBuffer(value: unknown): value is NodeJsBuffer; +}; + +// This can be nullish, but we gate the nodejs functions on being exported whether or not this exists +// Node.js global +declare const Buffer: NodeJsBufferConstructor; +declare const require: (mod: 'crypto') => { randomBytes: (byteLength: number) => Uint8Array }; + +/** @internal */ +export function nodejsMathRandomBytes(byteLength: number) { + return nodeJsByteUtils.fromNumberArray( + Array.from({ length: byteLength }, () => Math.floor(Math.random() * 256)), + ); +} + +/** + * @internal + * WARNING: REQUIRE WILL BE REWRITTEN + * + * This code is carefully used by require_rewriter.mjs any modifications must be reflected in the plugin. + * + * @remarks + * "crypto" is the only dependency BSON needs. This presents a problem for creating a bundle of the BSON library + * in an es module format that can be used both on the browser and in Node.js. In Node.js when BSON is imported as + * an es module, there will be no global require function defined, making the code below fallback to the much less desireable math.random bytes. + * In order to make our es module bundle work as expected on Node.js we need to change this `require()` to a dynamic import, and the dynamic + * import must be top-level awaited since es modules are async. So we rely on a custom rollup plugin to seek out the following lines of code + * and replace `require` with `await import` and the IIFE line (`nodejsRandomBytes = (() => { ... })()`) with `nodejsRandomBytes = await (async () => { ... })()` + * when generating an es module bundle. + */ +const nodejsRandomBytes: (byteLength: number) => Uint8Array = (() => { + try { + return require('crypto').randomBytes; + } catch { + return nodejsMathRandomBytes; + } +})(); + +/** @internal */ +export const nodeJsByteUtils = { + toLocalBufferType(potentialBuffer: Uint8Array | NodeJsBuffer | ArrayBuffer): NodeJsBuffer { + if (Buffer.isBuffer(potentialBuffer)) { + return potentialBuffer; + } + + if (ArrayBuffer.isView(potentialBuffer)) { + return Buffer.from( + potentialBuffer.buffer, + potentialBuffer.byteOffset, + potentialBuffer.byteLength, + ); + } + + const stringTag = + potentialBuffer?.[Symbol.toStringTag] ?? Object.prototype.toString.call(potentialBuffer); + if ( + stringTag === 'ArrayBuffer' || + stringTag === 'SharedArrayBuffer' || + stringTag === '[object ArrayBuffer]' || + stringTag === '[object SharedArrayBuffer]' + ) { + return Buffer.from(potentialBuffer); + } + + throw new BSONError(`Cannot create Buffer from ${String(potentialBuffer)}`); + }, + + allocate(size: number): NodeJsBuffer { + return Buffer.alloc(size); + }, + + equals(a: Uint8Array, b: Uint8Array): boolean { + return nodeJsByteUtils.toLocalBufferType(a).equals(b); + }, + + fromNumberArray(array: number[]): NodeJsBuffer { + return Buffer.from(array); + }, + + fromBase64(base64: string): NodeJsBuffer { + return Buffer.from(base64, 'base64'); + }, + + toBase64(buffer: Uint8Array): string { + return nodeJsByteUtils.toLocalBufferType(buffer).toString('base64'); + }, + + /** **Legacy** binary strings are an outdated method of data transfer. Do not add public API support for interpreting this format */ + fromISO88591(codePoints: string): NodeJsBuffer { + return Buffer.from(codePoints, 'binary'); + }, + + /** **Legacy** binary strings are an outdated method of data transfer. Do not add public API support for interpreting this format */ + toISO88591(buffer: Uint8Array): string { + return nodeJsByteUtils.toLocalBufferType(buffer).toString('binary'); + }, + + fromHex(hex: string): NodeJsBuffer { + return Buffer.from(hex, 'hex'); + }, + + toHex(buffer: Uint8Array): string { + return nodeJsByteUtils.toLocalBufferType(buffer).toString('hex'); + }, + + fromUTF8(text: string): NodeJsBuffer { + return Buffer.from(text, 'utf8'); + }, + + toUTF8(buffer: Uint8Array): string { + return nodeJsByteUtils.toLocalBufferType(buffer).toString('utf8'); + }, + + utf8ByteLength(input: string): number { + return Buffer.byteLength(input, 'utf8'); + }, + + encodeUTF8Into(buffer: Uint8Array, source: string, byteOffset: number): number { + return nodeJsByteUtils.toLocalBufferType(buffer).write(source, byteOffset, undefined, 'utf8'); + }, + + randomBytes: nodejsRandomBytes, +}; diff --git a/src/scalars/library/bson/utils/webByteUtils.ts b/src/scalars/library/bson/utils/webByteUtils.ts new file mode 100644 index 000000000..101d0df66 --- /dev/null +++ b/src/scalars/library/bson/utils/webByteUtils.ts @@ -0,0 +1,188 @@ +import { BSONError } from '../error.js'; + +type TextDecoder = { + readonly encoding: string; + readonly fatal: boolean; + readonly ignoreBOM: boolean; + decode(input?: Uint8Array): string; +}; +type TextDecoderConstructor = { + new (label: 'utf8', options: { fatal: boolean; ignoreBOM?: boolean }): TextDecoder; +}; + +type TextEncoder = { + readonly encoding: string; + encode(input?: string): Uint8Array; +}; +type TextEncoderConstructor = { + new (): TextEncoder; +}; + +// Web global +declare const TextDecoder: TextDecoderConstructor; +declare const TextEncoder: TextEncoderConstructor; +declare const atob: (base64: string) => string; +declare const btoa: (binary: string) => string; + +type ArrayBufferViewWithTag = ArrayBufferView & { + [Symbol.toStringTag]?: string; +}; + +function isReactNative() { + const { navigator } = globalThis as { navigator?: { product?: string } }; + return typeof navigator === 'object' && navigator.product === 'ReactNative'; +} + +/** @internal */ +export function webMathRandomBytes(byteLength: number) { + if (byteLength < 0) { + throw new RangeError(`The argument 'byteLength' is invalid. Received ${byteLength}`); + } + return webByteUtils.fromNumberArray( + Array.from({ length: byteLength }, () => Math.floor(Math.random() * 256)), + ); +} + +/** @internal */ +const webRandomBytes: (byteLength: number) => Uint8Array = (() => { + const { crypto } = globalThis as { + crypto?: { getRandomValues?: (space: Uint8Array) => Uint8Array }; + }; + if (crypto != null && typeof crypto.getRandomValues === 'function') { + return (byteLength: number) => { + return crypto.getRandomValues(webByteUtils.allocate(byteLength)); + }; + } else { + if (isReactNative()) { + const { console } = globalThis as { console?: { warn?: (message: string) => void } }; + console?.warn?.( + 'BSON: For React Native please polyfill crypto.getRandomValues, e.g. using: https://www.npmjs.com/package/react-native-get-random-values.', + ); + } + return webMathRandomBytes; + } +})(); + +const HEX_DIGIT = /(\d|[a-f])/i; + +/** @internal */ +export const webByteUtils = { + toLocalBufferType( + potentialUint8array: Uint8Array | ArrayBufferViewWithTag | ArrayBuffer, + ): Uint8Array { + const stringTag = + potentialUint8array?.[Symbol.toStringTag] ?? + Object.prototype.toString.call(potentialUint8array); + + if (stringTag === 'Uint8Array') { + return potentialUint8array as Uint8Array; + } + + if (ArrayBuffer.isView(potentialUint8array)) { + return new Uint8Array( + potentialUint8array.buffer.slice( + potentialUint8array.byteOffset, + potentialUint8array.byteOffset + potentialUint8array.byteLength, + ), + ); + } + + if ( + stringTag === 'ArrayBuffer' || + stringTag === 'SharedArrayBuffer' || + stringTag === '[object ArrayBuffer]' || + stringTag === '[object SharedArrayBuffer]' + ) { + return new Uint8Array(potentialUint8array); + } + + throw new BSONError(`Cannot make a Uint8Array from ${String(potentialUint8array)}`); + }, + + allocate(size: number): Uint8Array { + if (typeof size !== 'number') { + throw new TypeError(`The "size" argument must be of type number. Received ${String(size)}`); + } + return new Uint8Array(size); + }, + + equals(a: Uint8Array, b: Uint8Array): boolean { + if (a.byteLength !== b.byteLength) { + return false; + } + for (let i = 0; i < a.byteLength; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + }, + + fromNumberArray(array: number[]): Uint8Array { + return Uint8Array.from(array); + }, + + fromBase64(base64: string): Uint8Array { + return Uint8Array.from(atob(base64), c => c.charCodeAt(0)); + }, + + toBase64(uint8array: Uint8Array): string { + return btoa(webByteUtils.toISO88591(uint8array)); + }, + + /** **Legacy** binary strings are an outdated method of data transfer. Do not add public API support for interpreting this format */ + fromISO88591(codePoints: string): Uint8Array { + return Uint8Array.from(codePoints, c => c.charCodeAt(0) & 0xff); + }, + + /** **Legacy** binary strings are an outdated method of data transfer. Do not add public API support for interpreting this format */ + toISO88591(uint8array: Uint8Array): string { + return Array.from(Uint16Array.from(uint8array), b => String.fromCharCode(b)).join(''); + }, + + fromHex(hex: string): Uint8Array { + const evenLengthHex = hex.length % 2 === 0 ? hex : hex.slice(0, hex.length - 1); + const buffer: number[] = []; + + for (let i = 0; i < evenLengthHex.length; i += 2) { + const firstDigit = evenLengthHex[i]; + const secondDigit = evenLengthHex[i + 1]; + + if (!HEX_DIGIT.test(firstDigit)) { + break; + } + if (!HEX_DIGIT.test(secondDigit)) { + break; + } + + const hexDigit = Number.parseInt(`${firstDigit}${secondDigit}`, 16); + buffer.push(hexDigit); + } + + return Uint8Array.from(buffer); + }, + + toHex(uint8array: Uint8Array): string { + return Array.from(uint8array, byte => byte.toString(16).padStart(2, '0')).join(''); + }, + + fromUTF8(text: string): Uint8Array { + return new TextEncoder().encode(text); + }, + + toUTF8(uint8array: Uint8Array): string { + return new TextDecoder('utf8', { fatal: false }).decode(uint8array); + }, + + utf8ByteLength(input: string): number { + return webByteUtils.fromUTF8(input).byteLength; + }, + + encodeUTF8Into(buffer: Uint8Array, source: string, byteOffset: number): number { + const bytes = webByteUtils.fromUTF8(source); + buffer.set(bytes, byteOffset); + return bytes.byteLength; + }, + + randomBytes: webRandomBytes, +}; From 8bccc9b901168e51b49de5e3c0218df0370ea893 Mon Sep 17 00:00:00 2001 From: SeungWon Date: Sun, 2 Jul 2023 01:52:24 +0900 Subject: [PATCH 2/5] feat: parseValue, parseLiteral to return ObjectId --- src/scalars/ObjectID.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/scalars/ObjectID.ts b/src/scalars/ObjectID.ts index e06c989f8..4d28461cc 100644 --- a/src/scalars/ObjectID.ts +++ b/src/scalars/ObjectID.ts @@ -1,5 +1,6 @@ import { GraphQLScalarType, Kind, ValueNode } from 'graphql'; import { createGraphQLError } from '../error.js'; +import { ObjectID } from './library/bson/index.js'; const MONGODB_OBJECTID_REGEX = /*#__PURE__*/ /^[A-Fa-f0-9]{24}$/; @@ -22,7 +23,7 @@ export const GraphQLObjectID = /*#__PURE__*/ new GraphQLScalarType({ throw createGraphQLError(`Value is not a valid mongodb object id of form: ${value}`); } - return value; + return new ObjectID(value); }, parseLiteral(ast: ValueNode) { @@ -41,7 +42,7 @@ export const GraphQLObjectID = /*#__PURE__*/ new GraphQLScalarType({ }); } - return ast.value; + return new ObjectID(ast.value); }, extensions: { codegenScalarType: 'string', From 6ee00d81080bcf33a8e6a56a40ba235d726f6474 Mon Sep 17 00:00:00 2001 From: SeungWon Date: Sun, 2 Jul 2023 02:02:41 +0900 Subject: [PATCH 3/5] test: Add test cases for ObjectID scalar type --- tests/ObjectID.test.ts | 69 ++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/tests/ObjectID.test.ts b/tests/ObjectID.test.ts index 994cef512..49bcae244 100644 --- a/tests/ObjectID.test.ts +++ b/tests/ObjectID.test.ts @@ -1,23 +1,60 @@ /* global describe, test, expect */ import { Kind } from 'graphql/language'; - +import { ObjectId } from 'mongodb'; import { GraphQLObjectID } from '../src/scalars/ObjectID.js'; describe('ObjectId', () => { describe('valid', () => { test('serialize', () => { - expect(GraphQLObjectID.serialize('5e5677d71bdc2ae76344968c')).toBe('5e5677d71bdc2ae76344968c'); + expect(GraphQLObjectID.serialize('5e5677d71bdc2ae76344968c')).toBe( + '5e5677d71bdc2ae76344968c', + ); + }); + + test('parseValue toString', () => { + expect(GraphQLObjectID.parseValue('5e5677d71bdc2ae76344968c').toString()).toBe( + new ObjectId('5e5677d71bdc2ae76344968c').toString(), + ); }); - test('parseValue', () => { - expect(GraphQLObjectID.parseValue('5e5677d71bdc2ae76344968c')).toBe('5e5677d71bdc2ae76344968c'); + test('parseValue toHexString', () => { + expect(GraphQLObjectID.parseValue('5e5677d71bdc2ae76344968c').toHexString()).toBe( + new ObjectId('5e5677d71bdc2ae76344968c').toHexString(), + ); + }); + + test('parseValue toJSON', () => { + expect(GraphQLObjectID.parseValue('5e5677d71bdc2ae76344968c').toJSON()).toBe( + new ObjectId('5e5677d71bdc2ae76344968c').toJSON(), + ); + }); + + test('parseLiteral toString', () => { + expect( + GraphQLObjectID.parseLiteral( + { value: '5e5677d71bdc2ae76344968c', kind: Kind.STRING }, + undefined, + ).toString(), // undefined as prescribed by the Maybe type + ).toBe(new ObjectId('5e5677d71bdc2ae76344968c').toString()); + }); + + test('parseLiteral toHexString', () => { + expect( + GraphQLObjectID.parseLiteral( + { value: '5e5677d71bdc2ae76344968c', kind: Kind.STRING }, + undefined, + ).toHexString(), // undefined as prescribed by the Maybe type + ).toBe(new ObjectId('5e5677d71bdc2ae76344968c').toHexString()); }); - test('parseLiteral', () => { + test('parseLiteral toJSON', () => { expect( - GraphQLObjectID.parseLiteral({ value: '5e5677d71bdc2ae76344968c', kind: Kind.STRING }, undefined) // undefined as prescribed by the Maybe type - ).toBe('5e5677d71bdc2ae76344968c'); + GraphQLObjectID.parseLiteral( + { value: '5e5677d71bdc2ae76344968c', kind: Kind.STRING }, + undefined, + ).toJSON(), // undefined as prescribed by the Maybe type + ).toBe(new ObjectId('5e5677d71bdc2ae76344968c').toJSON()); }); }); @@ -26,21 +63,21 @@ describe('ObjectId', () => { test('serialize', () => { const invalid = '5e5677d71bdc2ae76344968z'; expect(() => GraphQLObjectID.serialize(invalid)).toThrow( - new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`) + new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`), ); }); test('parseValue', () => { const invalid = '5e5677d71bdc2ae76344968z'; expect(() => GraphQLObjectID.parseValue(invalid)).toThrow( - new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`) + new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`), ); }); test('parseLiteral', () => { const invalid = '5e5677d71bdc2ae76344968z'; expect( - () => GraphQLObjectID.parseLiteral({ value: invalid, kind: Kind.STRING }, undefined) // undefined as prescribed by the Maybe type + () => GraphQLObjectID.parseLiteral({ value: invalid, kind: Kind.STRING }, undefined), // undefined as prescribed by the Maybe type ).toThrow(new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`)); }); }); @@ -49,21 +86,21 @@ describe('ObjectId', () => { test('serialize', () => { const invalid = '5e5677d71bdc2ae'; expect(() => GraphQLObjectID.serialize(invalid)).toThrow( - new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`) + new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`), ); }); test('parseValue', () => { const invalid = '5e5677d71bdc2ae'; expect(() => GraphQLObjectID.parseValue(invalid)).toThrow( - new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`) + new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`), ); }); test('parseLiteral', () => { const invalid = '5e5677d71bdc2ae'; expect( - () => GraphQLObjectID.parseLiteral({ value: invalid, kind: Kind.STRING }, undefined) // undefined as prescribed by the Maybe type + () => GraphQLObjectID.parseLiteral({ value: invalid, kind: Kind.STRING }, undefined), // undefined as prescribed by the Maybe type ).toThrow(new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`)); }); }); @@ -72,21 +109,21 @@ describe('ObjectId', () => { test('serialize', () => { const invalid = '5e5677d71bdc2ae76344968c5'; expect(() => GraphQLObjectID.serialize(invalid)).toThrow( - new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`) + new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`), ); }); test('parseValue', () => { const invalid = '5e5677d71bdc2ae76344968c5'; expect(() => GraphQLObjectID.parseValue(invalid)).toThrow( - new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`) + new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`), ); }); test('parseLiteral', () => { const invalid = '5e5677d71bdc2ae76344968c5'; expect( - () => GraphQLObjectID.parseLiteral({ value: invalid, kind: Kind.STRING }, undefined) // undefined as prescribed by the Maybe type + () => GraphQLObjectID.parseLiteral({ value: invalid, kind: Kind.STRING }, undefined), // undefined as prescribed by the Maybe type ).toThrow(new RegExp(`Value is not a valid mongodb object id of form: ${invalid}`)); }); }); From 19b536939e48e697f050bb8abf8a09fb457d90e8 Mon Sep 17 00:00:00 2001 From: SeungWon Date: Sun, 2 Jul 2023 02:16:10 +0900 Subject: [PATCH 4/5] feat: mock ObjectID type string to ObjectID --- src/mocks.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mocks.ts b/src/mocks.ts index 1b24236de..e61c3aa47 100644 --- a/src/mocks.ts +++ b/src/mocks.ts @@ -1,3 +1,5 @@ +import { ObjectID } from './scalars/library/bson/index.js'; + const BigIntMock = () => BigInt(Number.MAX_SAFE_INTEGER); const ByteMock = () => new Uint8Array([1988, 1981, 1965, 1963, 1959, 1955]); const DateMock = () => '2007-12-03'; @@ -21,7 +23,7 @@ export const NonNegativeInt = () => 123; export const NonPositiveFloat = () => -123.45; export const NonPositiveInt = () => -123; export const PhoneNumber = () => '+17895551234'; -export const ObjectID = () => '5e5677d71bdc2ae76344968c'; +export const ObjectIDMock = () => new ObjectID('5e5677d71bdc2ae76344968c'); export const PositiveFloat = () => 123.45; export const PositiveInt = () => 123; export const PostalCode = () => '60031'; @@ -125,4 +127,5 @@ export { BigIntMock as BigInt, ByteMock as Byte, Duration as ISO8601Duration, + ObjectIDMock as ObjectID, }; From 7833cf07f92dcddc05cd909b8c23d2065f5eeb6b Mon Sep 17 00:00:00 2001 From: SeungWon Date: Sun, 2 Jul 2023 02:19:26 +0900 Subject: [PATCH 5/5] feat: Add support for ObjectID in serialize function --- src/scalars/ObjectID.ts | 9 +++++---- tests/ObjectID.test.ts | 8 +++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/scalars/ObjectID.ts b/src/scalars/ObjectID.ts index 4d28461cc..6c05bfbe9 100644 --- a/src/scalars/ObjectID.ts +++ b/src/scalars/ObjectID.ts @@ -10,12 +10,13 @@ export const GraphQLObjectID = /*#__PURE__*/ new GraphQLScalarType({ description: 'A field whose value conforms with the standard mongodb object ID as described here: https://docs.mongodb.com/manual/reference/method/ObjectId/#ObjectId. Example: 5e5677d71bdc2ae76344968c', - serialize(value: string) { - if (!MONGODB_OBJECTID_REGEX.test(value)) { - throw createGraphQLError(`Value is not a valid mongodb object id of form: ${value}`); + serialize(value: ObjectID | string) { + const valueToString = value.toString(); + if (!MONGODB_OBJECTID_REGEX.test(valueToString)) { + throw createGraphQLError(`Value is not a valid mongodb object id of form: ${valueToString}`); } - return value; + return valueToString; }, parseValue(value: string) { diff --git a/tests/ObjectID.test.ts b/tests/ObjectID.test.ts index 49bcae244..0c3021526 100644 --- a/tests/ObjectID.test.ts +++ b/tests/ObjectID.test.ts @@ -6,12 +6,18 @@ import { GraphQLObjectID } from '../src/scalars/ObjectID.js'; describe('ObjectId', () => { describe('valid', () => { - test('serialize', () => { + test('serialize string', () => { expect(GraphQLObjectID.serialize('5e5677d71bdc2ae76344968c')).toBe( '5e5677d71bdc2ae76344968c', ); }); + test('serialize ObjectId', () => { + expect(GraphQLObjectID.serialize(new ObjectId('5e5677d71bdc2ae76344968c'))).toBe( + '5e5677d71bdc2ae76344968c', + ); + }); + test('parseValue toString', () => { expect(GraphQLObjectID.parseValue('5e5677d71bdc2ae76344968c').toString()).toBe( new ObjectId('5e5677d71bdc2ae76344968c').toString(),