@camunda8/sdk
Version:
[](https://www.npmjs.com/package/@camunda8/sdk)
397 lines • 18 kB
JavaScript
;
/**
* This is a custom JSON Parser that handles lossless parsing of int64 numbers by using the lossless-json library.
*
* This is motivated by the use of int64 for Camunda 8 Entity keys, which are not supported by JavaScript's Number type.
* Variables could also contain unsafe large integers if an external system sends them to the broker.
*
* It converts all JSON numbers to lossless numbers, then converts them back to the correct type based on the metadata
* of a Dto class - fields decorated with `@Int64` are converted to a `string`, fields decorated with `@BigIntValue` are
* converted to `bigint`. All other numbers are converted to `number`. Throws if a number cannot be safely converted.
*
* It also handles nested Dtos by using the `@ChildDto` decorator.
*
* Update: added an optional `key` parameter to support the Camunda 8 REST API's use of an array under a key, e.g. { jobs : Job[] }
*
* Note: the parser uses DTO classes that extend the LosslessDto class to perform mappings of numeric types. However, only the type of
* the annotated numerics is type-checked at runtime. Fields of other types are not checked.
*
* More details on the design here: https://github.com/camunda/camunda-8-js-sdk/issues/81#issuecomment-2022213859
*
* See this article to understand why this is necessary: https://jsoneditoronline.org/indepth/parse/why-does-json-parse-corrupt-large-numbers/
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.losslessStringify = exports.losslessParse = exports.LosslessDto = exports.ChildDto = exports.BigIntValueArray = exports.BigIntValue = exports.Int64StringArray = exports.Int64String = void 0;
/* eslint-disable @typescript-eslint/no-explicit-any */
const debug_1 = require("debug");
const lossless_json_1 = require("lossless-json");
require("reflect-metadata");
const debug = (0, debug_1.debug)('lossless-json-parser');
const MetadataKey = {
INT64_STRING: 'type:int64',
INT64_STRING_ARRAY: 'type:int64[]',
INT64_BIGINT: 'type:bigint',
INT64_BIGINT_ARRAY: 'type:bigint[]',
CHILD_DTO: 'child:class',
};
/**
* Decorate Dto string fields as `@Int64String` to specify that the JSON number property should be parsed as a string.
* @example
* ```typescript
* class MyDto extends LosslessDto {
* @Int64String
* int64NumberField!: string
* @BigIntValue
* bigintField!: bigint
* @ChildDto(MyChildDto)
* childDtoField!: MyChildDto
* normalField!: string
* normalNumberField!: number
* maybePresentField?: string
* }
* ```
*/
function Int64String(target, propertyKey) {
Reflect.defineMetadata(MetadataKey.INT64_STRING, true, target, propertyKey);
}
exports.Int64String = Int64String;
/**
* Decorate Dto string fields as `@Int64StringArray` to specify that the array of JSON numbers should be parsed as an array of strings.
* @example
* ```typescript
* class Dto extends LosslessDto {
* message!: string
* userId!: number
* @Int64StringArray
* sendTo!: string[]
* }
*/
function Int64StringArray(target, propertyKey) {
Reflect.defineMetadata(MetadataKey.INT64_STRING_ARRAY, true, target, propertyKey);
}
exports.Int64StringArray = Int64StringArray;
/**
* Decorate Dto bigint fields as `@BigIntValue` to specify that the JSON number property should be parsed as a bigint.
* @example
* ```typescript
* class MyDto extends LosslessDto {
* @Int64String
* int64NumberField!: string
* @BigIntValue
* bigintField!: bigint
* @ChildDto(MyChildDto)
* childDtoField!: MyChildDto
* normalField!: string
* normalNumberField!: number
* maybePresentField?: string
* }
* ```
*/
function BigIntValue(target, propertKey) {
Reflect.defineMetadata(MetadataKey.INT64_BIGINT, true, target, propertKey);
}
exports.BigIntValue = BigIntValue;
/**
* Decorate Dto bigint fields as `@BigIntValueArray` to specify that the JSON number property should be parsed as a bigint.
* @example
* ```typescript
* class MyDto extends LosslessDto {
* @Int64String
* int64NumberField!: string
* @BigIntValueArray
* bigintField!: bigint[]
* @ChildDto(MyChildDto)
* childDtoField!: MyChildDto
* normalField!: string
* normalNumberField!: number
* maybePresentField?: string
* }
* ```
*/
function BigIntValueArray(target, propertKey) {
Reflect.defineMetadata(MetadataKey.INT64_BIGINT_ARRAY, true, target, propertKey);
}
exports.BigIntValueArray = BigIntValueArray;
/**
* Decorate a Dto object field as `@ChildDto` to specify that the JSON object property should be parsed as a child Dto.
* @example
* ```typescript
*
* class MyChildDto extends LosslessDto {
* someField!: string
* }
*
* class MyDto extends LosslessDto {
* @Int64String
* int64NumberField!: string
* @BigIntValue
* bigintField!: bigint
* @ChildDto(MyChildDto)
* childDtoField!: MyChildDto
* normalField!: string
* normalNumberField!: number
* maybePresentField?: string
* }
*/
function ChildDto(childClass) {
return function (target, propertyKey) {
Reflect.defineMetadata(MetadataKey.CHILD_DTO, childClass, target, propertyKey);
};
}
exports.ChildDto = ChildDto;
/**
* Extend the LosslessDto class with your own Dto classes to enable lossless parsing of int64 values.
* Decorate fields with `@Int64String` or `@BigIntValue` to specify how int64 JSON numbers should be parsed.
* @example
* ```typescript
* class MyDto extends LosslessDto {
* @Int64String
* int64NumberField: string
* @BigIntValue
* bigintField: bigint
* @ChildDto(MyChildDto)
* childDtoField: MyChildDto
* normalField: string
* normalNumberField: number
* }
* ```
*/
class LosslessDto {
}
exports.LosslessDto = LosslessDto;
/**
* losslessParse uses lossless-json parse to deserialize JSON.
* With no Dto, the parser will throw if it encounters an int64 number that cannot be safely represented as a JS number.
*
* @param json the JSON string to parse
* @param dto an annotated Dto class to parse the JSON string with
*/
function losslessParse(json, dto, keyToParse) {
/**
* lossless-json parse converts all numerics to LosslessNumber type instead of number type.
* Here we safely parse the string into an JSON object with all numerics as type LosslessNumber.
* This way we lose no fidelity at this stage, and can then use a supplied DTO to map large numbers
* or throw if we find an unsafe number.
*/
const parsedLossless = (0, lossless_json_1.parse)(json);
/**
* Specifying a keyToParse value applies all the mapping functionality to a key of the object in the JSON.
* gRPC API responses were naked objects or arrays of objects. REST response shapes typically have
* an array under an object key - eg: { jobs: [ ... ] }
*
* Since we now have a safely parsed object, we can recursively call losslessParse with the key, if it exists.
*/
if (keyToParse) {
if (parsedLossless[keyToParse]) {
return losslessParse((0, lossless_json_1.stringify)(parsedLossless[keyToParse]), dto);
}
/**
* A key was specified, but it was not found on the parsed object.
* At this point we should throw, because we cannot perform the operation requested. Something has gone wrong with
* the expected shape of the response.
*
* We throw an error with the actual shape of the object to help with debugging.
*/
throw new Error(`Attempted to parse key ${keyToParse} on an object that does not have this key: ${(0, lossless_json_1.stringify)(parsedLossless)}`);
}
if (Array.isArray(parsedLossless)) {
debug(`Array input detected. Parsing array.`);
return parseArrayWithAnnotations(json, dto ?? LosslessDto);
}
if (!dto) {
debug(`No Dto class provided. Parsing without annotations (safe parse).`);
return convertLosslessNumbersToNumberOrThrow(parsedLossless);
}
debug(`Got a Dto ${dto.name}. Parsing with annotations.`);
const parsed = parseWithAnnotations(parsedLossless, dto);
debug(`Converting remaining lossless numbers to numbers for ${dto.name}`);
/** All numbers are parsed to LosslessNumber by lossless-json. For any fields that should be numbers, we convert them
* now to number. Because we expose large values as string or BigInt, the only Lossless numbers left on the object
* are unmapped. So at this point we convert all remaining LosslessNumbers to number type if safe, and throw if not.
*/
return convertLosslessNumbersToNumberOrThrow(parsed);
}
exports.losslessParse = losslessParse;
function parseWithAnnotations(obj, dto) {
const instance = new dto();
for (const [key, value] of Object.entries(obj)) {
const childClass = Reflect.getMetadata(MetadataKey.CHILD_DTO, dto.prototype, key);
if (childClass) {
if (Array.isArray(value)) {
// If the value is an array, parse each element with the specified child class
instance[key] = value.map((item) => losslessParse((0, lossless_json_1.stringify)(item), childClass));
}
else {
// If the value is an object, parse it with the specified child class
instance[key] = losslessParse((0, lossless_json_1.stringify)(value), childClass);
}
}
else {
if (Reflect.hasMetadata(MetadataKey.INT64_STRING_ARRAY, dto.prototype, key)) {
debug(`Parsing int64 array field "${key}" to string`);
if (Array.isArray(value)) {
instance[key] = value.map((item) => {
// item is already a string - from 8.7, the broker returns strings for int64 entity keys
if (typeof item === 'string') {
return item;
}
if ((0, lossless_json_1.isLosslessNumber)(item)) {
return item.toString();
}
else {
debug('Unexpected type for value', value);
throw new Error(`Unexpected type: Received JSON ${typeof item} value for Int64String Dto field "${key}", expected number`);
}
});
}
else {
const type = value instanceof lossless_json_1.LosslessNumber ? 'number' : typeof value;
throw new Error(`Unexpected type: Received JSON ${type} value for Int64StringArray Dto field "${key}", expected Array`);
}
}
else if (Reflect.hasMetadata(MetadataKey.INT64_STRING, dto.prototype, key)) {
debug(`Parsing int64 field "${key}" to string`);
if (value) {
// value is already a string - from 8.7, the broker returns strings for int64 entity keys
if (typeof value === 'string') {
instance[key] = value;
}
else if ((0, lossless_json_1.isLosslessNumber)(value)) {
instance[key] = value.toString();
}
else {
if (Array.isArray(value)) {
throw new Error(`Unexpected type: Received JSON array value for Int64String Dto field "${key}", expected number. If you are expecting an array, use the @Int64StringArray decorator.`);
}
const type = value instanceof lossless_json_1.LosslessNumber ? 'number' : typeof value;
throw new Error(`Unexpected type: Received JSON ${type} value for Int64String Dto field "${key}", expected number`);
}
}
}
else if (Reflect.hasMetadata(MetadataKey.INT64_BIGINT_ARRAY, dto.prototype, key)) {
debug(`Parsing int64 array field "${key}" to BigInt`);
if (Array.isArray(value)) {
instance[key] = value.map((item) => {
if ((0, lossless_json_1.isLosslessNumber)(item)) {
return BigInt(item.toString());
}
else {
debug('Unexpected type for value', value);
throw new Error(`Unexpected type: Received JSON ${typeof item} value for BigIntValue in Dto field "${key}[]", expected number`);
}
});
}
else {
const type = value instanceof lossless_json_1.LosslessNumber ? 'number' : typeof value;
throw new Error(`Unexpected type: Received JSON ${type} value for BigIntValueArray Dto field "${key}", expected Array`);
}
}
else if (Reflect.hasMetadata(MetadataKey.INT64_BIGINT, dto.prototype, key)) {
debug(`Parsing bigint field ${key}`);
if (value) {
if ((0, lossless_json_1.isLosslessNumber)(value)) {
instance[key] = BigInt(value.toString());
}
else {
if (Array.isArray(value)) {
throw new Error(`Unexpected type: Received JSON array value for BigIntValue Dto field "${key}", expected number. If you are expecting an array, use the @BigIntValueArray decorator.`);
}
throw new Error(`Unexpected type: Received JSON ${typeof value} value for BigIntValue Dto field "${key}", expected number`);
}
}
}
else {
instance[key] = value; // Assign directly for other types
}
}
}
return instance;
}
function parseArrayWithAnnotations(json, dto) {
const array = (0, lossless_json_1.parse)(json);
return array.map((item) => losslessParse((0, lossless_json_1.stringify)(item), dto));
}
/**
* Convert all `LosslessNumber` instances to a number or throw if any are unsafe.
*
* All numerics are converted to LosslessNumbers by lossless-json parse. Then, if a DTO was provided,
* all mappings have been done to either BigInt or string type. So all remaining LosslessNumbers in the object
* are either unmapped or mapped to number.
*
* Here we convert all remaining LosslessNumbers to a safe number value, or throw if an unsafe value is detected.
*/
function convertLosslessNumbersToNumberOrThrow(obj) {
debug(`Parsing LosslessNumbers to numbers for ${obj?.constructor?.name}`);
if (!obj) {
return obj;
}
if (obj instanceof lossless_json_1.LosslessNumber) {
return (0, lossless_json_1.toSafeNumberOrThrow)(obj.toString());
}
let currentKey = '';
try {
Object.keys(obj).forEach((key) => {
currentKey = key;
if (Array.isArray(obj[key])) {
// If the value is an array, iterate over it and recursively call the function on each element
obj[key].forEach((item, index) => {
obj[key][index] = convertLosslessNumbersToNumberOrThrow(item);
});
}
else if ((0, lossless_json_1.isLosslessNumber)(obj[key])) {
debug(`Converting LosslessNumber ${key} to number`);
obj[key] = (0, lossless_json_1.toSafeNumberOrThrow)(obj[key].toString());
}
else if (typeof obj[key] === 'object' && obj[key] !== null) {
// If the value is an object, recurse into it
obj[key] = convertLosslessNumbersToNumberOrThrow(obj[key]);
}
});
}
catch (e) {
const message = e.message;
throw new Error(`An unsafe number value was received for "${currentKey}" and no Dto mapping was specified.\n` +
message);
}
return obj;
}
function losslessStringify(obj, isTopLevel = true) {
const isLosslessDto = obj instanceof LosslessDto;
debug(`Stringifying ${isLosslessDto ? obj.constructor.name : 'object'}`);
if (!isLosslessDto) {
debug(`Object is not a LosslessDto. Stringifying as normal JSON.`);
}
if (obj instanceof Date) {
throw new Error(`Date type not supported in variables. Please serialize with .toISOString() before passing to Camunda`);
}
if (obj instanceof Map) {
throw new Error(`Map type not supported in variables. Please serialize with Object.fromEntries() before passing to Camunda`);
}
if (obj instanceof Set) {
throw new Error(`Set type not supported in variables. Please serialize with Array.from() before passing to Camunda`);
}
const newObj = Array.isArray(obj) ? [] : {};
Object.keys(obj).forEach((key) => {
const value = obj[key];
if (typeof value === 'object' && value !== null) {
// If the value is an object or array, recurse into it
newObj[key] = losslessStringify(value, false);
}
else if (Reflect.getMetadata(MetadataKey.INT64_STRING, obj, key)) {
// If the property is decorated with @Int64String, convert the string to a LosslessNumber
debug(`Stringifying int64 string field ${key}`);
newObj[key] = new lossless_json_1.LosslessNumber(value);
}
else if (Reflect.getMetadata(MetadataKey.INT64_BIGINT, obj, key)) {
// If the property is decorated with @BigIntValue, convert the bigint to a LosslessNumber
debug(`Stringifying bigint field ${key}`);
newObj[key] = new lossless_json_1.LosslessNumber(value.toString());
}
else {
newObj[key] = value;
}
});
return isTopLevel ? (0, lossless_json_1.stringify)(newObj) : newObj;
}
exports.losslessStringify = losslessStringify;
//# sourceMappingURL=LosslessJsonParser.js.map