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

Integrating JSON serialization and deserialization #2

Merged
merged 3 commits into from
May 9, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion src/AbstractError.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { POJO } from './types';
import type { POJO, Class } from './types';
import { performance } from 'perf_hooks';
import { CustomError } from 'ts-custom-error';

Expand All @@ -12,6 +12,31 @@ class AbstractError<T> extends CustomError {
*/
public static description: string = '';

public static fromJSON<T extends Class<any>>(
this: T,
json: any,
): InstanceType<T> {
if (
typeof json !== 'object' ||
json.type !== this.name ||
typeof json.data !== 'object' ||
typeof json.data.message !== 'string' ||
isNaN(Date.parse(json.data.timestamp)) ||
typeof json.data.data !== 'object' ||
!('cause' in json.data) ||
('stack' in json.data && typeof json.data.stack !== 'string')
) {
throw new TypeError(`Cannot decode JSON to ${this.name}`);
}
const e = new this(json.data.message, {
timestamp: new Date(json.data.timestamp),
data: json.data.data,
cause: json.data.cause,
});
e.stack = json.data.stack;
return e;
}

/**
* Arbitrary data
*/
Expand Down Expand Up @@ -48,6 +73,19 @@ class AbstractError<T> extends CustomError {
public get description(): string {
return this.constructor['description'];
}

public toJSON(): any {
return {
type: this.constructor.name,
data: {
message: this.message,
timestamp: this.timestamp,
data: this.data,
cause: this.cause,
stack: this.stack,
},
};
}
}

export default AbstractError;
4 changes: 3 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
*/
type POJO = { [key: string]: any };

export type { POJO };
type Class<T> = new (...args: any[]) => T;

export type { POJO, Class };
197 changes: 197 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Class } from '@/types';
import { AbstractError } from '@';

describe('index', () => {
Expand Down Expand Up @@ -55,4 +56,200 @@ describe('index', () => {
expect(e.cause).toBe(eOriginal);
}
});
test('inheritance', () => {
// Propagate the cause, allow cause to be generic
// and the cause is determined by the instantiator
class ErrorPropagate<T> extends AbstractError<T> {}
// Fix the cause, so that `SyntaxError` is always the cause
class ErrorFixed extends AbstractError<SyntaxError> {}
// Default the cause, but allow instantiator to override
class ErrorDefault<T = TypeError> extends AbstractError<T> {}
const eP = new ErrorPropagate<string>(undefined, { cause: 'string' });
const eF = new ErrorFixed(undefined, { cause: new SyntaxError() });
const eD = new ErrorDefault<number>(undefined, { cause: 123 });
expect(eP.cause).toBe('string');
expect(eF.cause).toBeInstanceOf(SyntaxError);
expect(eD.cause).toBe(123);
});
test('JSON encoding/decoding', () => {
const e = new AbstractError();
expect(AbstractError.fromJSON(e.toJSON())).toBeInstanceOf(AbstractError);
expect(AbstractError.fromJSON(e.toJSON()).toJSON()).toStrictEqual(
e.toJSON(),
);
const eJSON = {
type: 'AbstractError',
data: {
message: 'some message',
timestamp: '2022-05-07T09:16:06.632Z',
data: {},
cause: undefined,
},
};
const e2 = AbstractError.fromJSON(eJSON);
expect(e2).toBeInstanceOf(AbstractError);
expect(e2.message).toBe(eJSON.data.message);
expect(e2.timestamp).toStrictEqual(new Date(eJSON.data.timestamp));
expect(e2.data).toStrictEqual(eJSON.data.data);
expect(e2.cause).toStrictEqual(eJSON.data.cause);
});
describe('JSON serialiation and deserialisation', () => {
// Demonstrates an extended error with its own encoding and decoding
class SpecificError<T> extends AbstractError<T> {
public static fromJSON<T extends Class<any>>(
this: T,
json: any,
): InstanceType<T> {
if (
typeof json !== 'object' ||
json.type !== this.name ||
typeof json.data !== 'object' ||
typeof json.data.message !== 'string' ||
typeof json.data.num !== 'number' ||
('stack' in json.data && typeof json.data.stack !== 'string')
) {
throw new TypeError(`Cannot decode JSON to ${this.name}`);
}
const e = new this(json.data.num, json.data.message);
e.stack = json.data.stack;
return e;
}
public num: number;
public constructor(num: number, message: string) {
super(message);
this.num = num;
}
public toJSON() {
const obj = super.toJSON();
obj.data.num = this.num;
return obj;
}
}
class UnknownError<T> extends AbstractError<T> {}
// AbstractError classes, these should be part of our application stack
const customErrors = {
AbstractError,
SpecificError,
UnknownError,
};
// Standard JS errors, these do not have fromJSON routines
const standardErrors = {
Error,
TypeError,
SyntaxError,
ReferenceError,
EvalError,
RangeError,
URIError,
};
CMCDragonkai marked this conversation as resolved.
Show resolved Hide resolved
function replacer(key: string, value: any): any {
if (value instanceof Error) {
return {
type: value.constructor.name,
data: {
message: value.message,
stack: value.stack,
},
CMCDragonkai marked this conversation as resolved.
Show resolved Hide resolved
};
} else {
return value;
}
}
// Assume that the purpose of the reviver is to deserialise JSON string
// back into exceptions
// The reviver has 3 choices when encountering an unknown value
// 1. throw an "parse" exception
// 2. return the value as it is
// 3. return as special "unknown" exception that contains the unknown value as data
// Choice 1. results in strict deserialisation procedure (no forwards-compatibility)
// Choice 2. results in ambiguous parsed result
// Choice 3. is the best option as it ensures a typed-result and debuggability of ambiguous data
function reviver(key: string, value: any): any {
if (
typeof value === 'object' &&
typeof value.type === 'string' &&
typeof value.data === 'object'
) {
try {
let eClass = customErrors[value.type];
if (eClass != null) return eClass.fromJSON(value);
eClass = standardErrors[value.type];
if (eClass != null) {
if (
typeof value.data.message !== 'string' ||
('stack' in value.data && typeof value.data.stack !== 'string')
) {
throw new TypeError(`Cannot decode JSON to ${value.type}`);
}
const e = new eClass(value.data.message);
CMCDragonkai marked this conversation as resolved.
Show resolved Hide resolved
e.stack = value.data.stack;
return e;
}
} catch (e) {
// If `TypeError` which represents decoding failure
// then return value as-is
// Any other exception is a bug
if (!(e instanceof TypeError)) {
throw e;
}
}
// Other values are returned as-is
return value;
} else if (key === '') {
// Root key will be ''
// Reaching here means the root JSON value is not a valid exception
// Therefore UnknownError is only ever returned at the top-level
return new UnknownError('Unknown error JSON', {
data: {
json: value,
},
});
} else {
// Other values will be returned as-is
return value;
}
}
test('abstract on specific', () => {
const e = new AbstractError('msg1', {
cause: new SpecificError(123, 'msg2'),
});
const eJSONString = JSON.stringify(e, replacer);
const e_ = JSON.parse(eJSONString, reviver);
expect(e_).toBeInstanceOf(AbstractError);
expect(e_.message).toBe(e.message);
expect(e_.cause).toBeInstanceOf(SpecificError);
expect(e_.cause.message).toBe(e.cause.message);
});
test('abstract on abstract on range', () => {
const e = new AbstractError('msg1', {
cause: new AbstractError('msg2', {
cause: new RangeError('msg3'),
}),
});
const eJSONString = JSON.stringify(e, replacer);
const e_ = JSON.parse(eJSONString, reviver);
expect(e_).toBeInstanceOf(AbstractError);
expect(e_.message).toBe(e.message);
expect(e_.cause).toBeInstanceOf(AbstractError);
expect(e_.cause.message).toBe(e.cause.message);
expect(e_.cause.cause).toBeInstanceOf(RangeError);
expect(e_.cause.cause.message).toBe(e.cause.cause.message);
});
test('abstract on something random', () => {
const e = new AbstractError('msg1', {
cause: 'something random',
});
const eJSONString = JSON.stringify(e, replacer);
const e_ = JSON.parse(eJSONString, reviver);
expect(e_).toBeInstanceOf(AbstractError);
expect(e_.message).toBe(e.message);
expect(e_.cause).toBe('something random');
});
test('unknown at root', () => {
const e = '123';
const eJSONString = JSON.stringify(e, replacer);
const e_ = JSON.parse(eJSONString, reviver);
expect(e_).toBeInstanceOf(UnknownError);
});
});
});