feat: EffectFactory validation + dedicated CoreError stack traces
MODIFY
packages/core/src/effects/effects.factory.ts
packages/core/src/effects/effects.factory.ts
-import { HttpMethod } from '../http.interface';
+import { HttpMethod, HttpMethodType } from '../http.interface';
import { Effect, EffectResponse } from './effects.interface';
import { RouteEffect } from '../router/router.interface';
+import { coreErrorFactory, CoreErrorOptions } from '../error/error.factory';
+import { getArrayFromEnum } from '../util/array.util';
export namespace EffectFactory {
- export const matchPath = (path: string) => ({
- matchType: (method: HttpMethod) => ({
- use: (effect: Effect<EffectResponse>): RouteEffect => ({
- path, method, effect
- }),
- }),
- });
+ const httpMethods = getArrayFromEnum(HttpMethodType);
+ const coreErrorOptions: CoreErrorOptions = { contextMethod: 'EffectFactory' };
+
+ export const matchPath = (path: string) => {
+ if (!path) {
+ throw coreErrorFactory('Path cannot be empty', coreErrorOptions);
+ }
+
+ return { matchType: matchType(path) };
+ };
+
+ const matchType = (path: string) => (method: HttpMethod) => {
+ if (!method) {
+ throw coreErrorFactory('HttpMethod needs to be defined', coreErrorOptions);
+ }
+
+ if (!httpMethods.includes(method)) {
+ throw coreErrorFactory(
+ `HttpMethod needs to be one of the following: ${httpMethods}`,
+ coreErrorOptions,
+ );
+ }
+
+ return { use: use(path)(method) };
+ };
+
+ const use = (path: string) => (method: HttpMethod) => (effect: Effect<EffectResponse>): RouteEffect => {
+ if (!effect) {
+ throw coreErrorFactory('Effect needs to be provided', coreErrorOptions);
+ }
+
+ return { path, method, effect };
+ };
}

MODIFY
packages/core/src/effects/specs/effects.factory.spec.ts
packages/core/src/effects/specs/effects.factory.spec.ts
import { mapTo } from 'rxjs/operators';
import { Effect } from '../effects.interface';
import { EffectFactory } from '../effects.factory';
+import { HttpMethod } from '../../http.interface';
-describe('Effects factory', () => {
+describe('EffectFactory', () => {
- test('#effect factorizes RouteConfig', () => {
+ let expectedError: Error;
+ let coreError;
+
+ beforeEach(() => {
+ jest.unmock('../../error/error.factory.ts');
+ coreError = require('../../error/error.factory.ts');
+ coreError.coreErrorFactory = jest.fn(() => expectedError);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('factorizes RouteConfig', () => {
// given
const effect$: Effect = req$ => req$.pipe(mapTo({ body: 'test' }));
const path = '/foo';
@@ -24,4 +38,57 @@ describe('Effects factory', () => {
});
});
+ test('throws an error if "path" is not provided', () => {
+ // given
+ expectedError = new Error('Path cannot be empty');
+
+ // when
+ const effectFactoryFn = () => EffectFactory.matchPath(undefined);
+
+ // then
+ expect(effectFactoryFn).toThrowError(expectedError);
+ });
+
+ test('throws an error if "mathod" is not provided', () => {
+ // given
+ expectedError = new Error('HttpMethod needs to be defined');
+
+ // when
+ const effectFactoryFn = () => EffectFactory
+ .matchPath('/')
+ .matchType(undefined);
+
+ // then
+ expect(effectFactoryFn).toThrowError(expectedError);
+ });
+
+ test('throws an error if provided "mathod" is not included in the list', () => {
+ // given
+ expectedError = new Error(
+ 'HttpMethod needs to be one of the following: POST,PUT,PATCH,GET,HEAD,DELETE,CONNECT,OPTIONS,TRACE,*'
+ );
+
+ // when
+ const effectFactoryFn = () => EffectFactory
+ .matchPath('/')
+ .matchType('TEST' as HttpMethod);
+
+ // then
+ expect(effectFactoryFn).toThrowError(expectedError);
+ });
+
+ test('throws an error if "effect" is not provided', () => {
+ // given
+ expectedError = new Error('Effect needs to be provided');
+
+ // when
+ const effectFactoryFn = () => EffectFactory
+ .matchPath('/')
+ .matchType('GET')
+ .use(undefined);
+
+ // then
+ expect(effectFactoryFn).toThrowError(expectedError);
+ });
+
});

MODIFY
packages/core/src/error/error.effect.ts
packages/core/src/error/error.effect.ts
@@ -17,9 +17,9 @@ const errorFactory = (message: string, status: HttpStatus, data?: any) => ({
error: { status, message, data }
});
-export const error$: Effect<EffectResponse, ThrownError> = (request$, response, error) => request$
+export const error$: Effect<EffectResponse, ThrownError> = (request$, _, error) => request$
.pipe(
- map(req => {
+ map(() => {
const { message, data } = error;
const status = getStatusCode(error);
const body = errorFactory(message, status, data);

MODIFY
packages/core/src/error/error.model.ts
packages/core/src/error/error.model.ts
@@ -16,5 +16,20 @@ export class HttpError extends ExtendableError {
}
}
+export class CoreError extends ExtendableError {
+ constructor(
+ public readonly message: string,
+ options: {
+ stackTraceFactory: (message: string, stack: NodeJS.CallSite[]) => string,
+ context: any,
+ }
+ ) {
+ super('CoreError', message);
+
+ Error.prepareStackTrace = (_, stack) => options.stackTraceFactory(message, stack);
+ Error.captureStackTrace(this, options.context);
+ }
+}
+
export const isHttpError = (error: Error): error is HttpError =>
error.name === 'HttpError';

ADD
packages/core/src/error/specs/error.factory.spec.ts
+import { CoreErrorOptions, coreErrorFactory, coreErrorStackTraceFactory } from '../error.factory';
+
+const getMockedStackTrace = (opts: any = {}) => [
+ { getMethodName: jest.fn(() => opts.methodName) },
+ {
+ getFileName: jest.fn(() => opts.filename),
+ getLineNumber: jest.fn(() => opts.lineNumber),
+ getColumnNumber: jest.fn(() => opts.columnNumber),
+ },
+] as any as NodeJS.CallSite[];
+
+describe('Error factory', () => {
+
+ describe('#coreErrorStackTraceFactory', () => {
+ test('factorizes stack trace', () => {
+ // given
+ const message = 'test-message';
+ const stack = getMockedStackTrace({
+ lineNumber: 1,
+ columnNumber: 2,
+ methodName: 'test-method',
+ filename: 'test-filename',
+ });
+ const options: CoreErrorOptions = {
+ contextMethod: 'test-context-method',
+ contextPackage: 'test-context-package',
+ };
+
+ // when
+ const factorizedStackTrace = coreErrorStackTraceFactory(options)(message, stack);
+
+ // then
+ expect(factorizedStackTrace.includes('test-context-package')).toEqual(true);
+ expect(factorizedStackTrace.includes('test-message')).toEqual(true);
+ expect(factorizedStackTrace.includes('test-context-method : test-method'));
+ expect(factorizedStackTrace.includes('test-filename')).toEqual(true);
+ expect(factorizedStackTrace.includes('[1:2]')).toEqual(true);
+ });
+
+ test('factorizes stack trace with undefined attributes', () => {
+ // given
+ const message = 'test-message';
+ const stack = getMockedStackTrace({
+ lineNumber: undefined,
+ columnNumber: undefined,
+ methodName: undefined,
+ filename: undefined,
+ });
+ const options: CoreErrorOptions = {
+ contextMethod: 'test-context-method',
+ };
+
+ // when
+ const factorizedStackTrace = coreErrorStackTraceFactory(options)(message, stack);
+
+ // then
+ expect(factorizedStackTrace.includes('@marblejs/core')).toEqual(true);
+ expect(factorizedStackTrace.includes('test-message')).toEqual(true);
+ expect(factorizedStackTrace.includes('test-context-method : -')).toEqual(true);
+ expect(factorizedStackTrace.includes('[0:0]')).toEqual(true);
+ });
+ });
+
+ describe('#coreErrorFactory', () => {
+ beforeEach(() => {
+ jest.unmock('../error.factory.ts');
+ require('../error.factory.ts').coreErrorStackTraceFactory = jest.fn(() => jest.fn());
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('factorizes CoreError', () => {
+ // given
+ const message = 'test-message';
+ const options = {} as CoreErrorOptions;
+
+ // when
+ const factorizedError = coreErrorFactory(message, options);
+
+ // then
+ expect(factorizedError).toBeDefined();
+ expect(factorizedError.message).toEqual(message);
+ });
+
+ test('factorizes CoreError when message is empty', () => {
+ // given
+ const message = '';
+ const options = {} as CoreErrorOptions;
+
+ // when
+ const factorizedError = coreErrorFactory(message, options);
+
+ // then
+ expect(factorizedError).toBeDefined();
+ expect(factorizedError.message).toEqual('Something is not right...');
+ });
+ });
+
+});

MODIFY
packages/core/src/error/specs/error.model.spec.ts
packages/core/src/error/specs/error.model.spec.ts
-import { ExtendableError, HttpError, isHttpError } from '../error.model';
+import { ExtendableError, HttpError, isHttpError, CoreError } from '../error.model';
-describe('Error', () => {
+describe('Error model', () => {
- it('#ExtendableError creates error object', () => {
+ beforeEach(() => {
+ jest.spyOn(Error, 'captureStackTrace');
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('#ExtendableError creates error object', () => {
const error = new ExtendableError('TestError', 'test-message');
+
expect(error.name).toBe('TestError');
expect(error.message).toBe('test-message');
});
- it('#HttpError creates error object', () => {
+ test('#HttpError creates error object', () => {
const error = new HttpError('test-message', 200);
+
expect(error.name).toBe('HttpError');
expect(error.status).toBe(200);
expect(error.message).toBe('test-message');
});
- it('#isHttpError detects HttpError type', () => {
+ test('#CoreError creates error object', () => {
+ // given
+ const stackTraceFactory = jest.fn();
+ const error = new CoreError('test-message', {
+ context: {},
+ stackTraceFactory,
+ });
+
+ // when
+ Error.prepareStackTrace(error, []);
+
+ // then
+ expect(error.name).toBe('CoreError');
+ expect(error.message).toBe('test-message');
+ expect(Error.captureStackTrace).toHaveBeenCalled();
+ expect(stackTraceFactory).toHaveBeenCalledWith('test-message', []);
+ });
+
+ test('#isHttpError detects HttpError type', () => {
const httpError = new HttpError('test-message', 200);
const otherError = new Error();

MODIFY
packages/core/src/http.interface.ts
packages/core/src/http.interface.ts
@@ -20,17 +20,20 @@ export interface HttpResponse extends http.ServerResponse {
export type HttpHeaders = Record<string, string>;
-export type HttpMethod =
- | 'POST'
- | 'PUT'
- | 'PATCH'
- | 'GET'
- | 'HEAD'
- | 'DELETE'
- | 'CONNECT'
- | 'OPTIONS'
- | 'TRACE'
- | '*';
+export enum HttpMethodType {
+ POST,
+ PUT,
+ PATCH,
+ GET,
+ HEAD,
+ DELETE,
+ CONNECT,
+ OPTIONS,
+ TRACE,
+ '*',
+}
+
+export type HttpMethod = keyof typeof HttpMethodType;
export type Http = {
req: HttpRequest;

ADD
packages/core/src/util/array.util.ts
+export const getArrayFromEnum = (E: Object) =>
+ Object.keys(E).filter(key => typeof E[key as any] === 'number');

feat: EffectFactory validation + dedicated CoreError stack traces