fix(core): handle gracefuly middlewares errors
MODIFY
packages/core/src/effects/http-effects.interface.ts
packages/core/src/effects/http-effects.interface.ts
import { HttpRequest, HttpStatus, HttpHeaders, HttpServer } from '../http.interface';
-import { HttpError } from '../error/error.model';
import { Event } from '../event/event.interface';
import { Effect } from './effects.interface';
@@ -15,7 +14,7 @@ export interface HttpMiddlewareEffect<
> extends HttpEffect<I, O> {}
export interface HttpErrorEffect<
- Err extends Error = HttpError,
+ Err extends Error = Error,
> extends HttpEffect<{ req: HttpRequest; error: Err }, HttpEffectResponse> {}
export interface HttpServerEffect<

MODIFY
packages/core/src/error/error.effect.ts
packages/core/src/error/error.effect.ts
@@ -18,7 +18,7 @@ const errorFactory = (status: HttpStatus, error: Error) =>
? { error: { status, message: error.message, data: error.data, context: error.context } }
: { error: { status, message: error.message } };
-export const defaultError$: HttpErrorEffect<HttpError> = req$ =>
+export const defaultError$: HttpErrorEffect<Error> = req$ =>
req$.pipe(
map(({ error = defaultHttpError }) => {
const status = getStatusCode(error);

MODIFY
packages/core/src/error/error.model.ts
packages/core/src/error/error.model.ts
import { Event } from '../event/event.interface';
-import { HttpStatus } from '../http.interface';
+import { HttpStatus, HttpRequest } from '../http.interface';
import { ExtendableError } from '../+internal/utils';
export enum ErrorType {
CORE_ERROR = 'CoreError',
CONTEXT_ERROR = 'ContextError',
HTTP_ERROR = 'HttpError',
+ EFFECT_ERROR = 'EffectError',
EVENT_ERROR = 'EventError',
}
+export class EffectError extends ExtendableError {
+ constructor(
+ public readonly error: Error,
+ public readonly req: HttpRequest,
+ ) {
+ super(ErrorType.EFFECT_ERROR, '');
+ }
+}
+
export class HttpError extends ExtendableError {
constructor(
public readonly message: string,

MODIFY
packages/core/src/listener/http.listener.spec.ts
packages/core/src/listener/http.listener.spec.ts
-import { IncomingMessage, OutgoingMessage } from 'http';
-import { of, throwError } from 'rxjs';
-import { mapTo, switchMap } from 'rxjs/operators';
+import * as http from 'http';
+import { throwError } from 'rxjs';
+import { mapTo, mergeMap } from 'rxjs/operators';
+import { r } from '../router/router.ixbuilder';
import { httpListener } from './http.listener';
-import { EffectFactory } from '../effects/effects.factory';
-import { HttpMiddlewareEffect } from '../effects/http-effects.interface';
+import { HttpMiddlewareEffect, HttpEffectResponse } from '../effects/http-effects.interface';
import { createContext, registerAll, bindTo } from '../context/context.factory';
import { ServerClientToken } from '../server/server.tokens';
describe('Http listener', () => {
- let effectsCombiner;
let responseHandler;
- let routerFactory;
- let routerResolver;
const context = registerAll([
- bindTo(ServerClientToken)(jest.fn),
+ bindTo(ServerClientToken)(() => http.createServer()),
])(createContext());
- const effect$ = EffectFactory
- .matchPath('/')
- .matchType('GET')
- .use(req$ => req$.pipe(mapTo( {} )));
-
- const middleware$: HttpMiddlewareEffect = req$ => req$;
-
beforeEach(() => {
- jest.unmock('../error/error.effect.ts');
- jest.unmock('../effects/effects.combiner.ts');
jest.unmock('../response/response.handler.ts');
- jest.unmock('../router/router.factory.ts');
- jest.unmock('../router/router.resolver.ts');
-
- effectsCombiner = require('../effects/effects.combiner.ts');
responseHandler = require('../response/response.handler.ts');
- routerFactory = require('../router/router.factory.ts');
- routerResolver = require('../router/router.resolver.ts');
});
test('#httpListener handles received HttpRequest', done => {
// given
- const req = {} as IncomingMessage;
- const res = {} as OutgoingMessage;
+ const req = { url: '/', method: 'GET' } as http.IncomingMessage;
+ const res = {} as http.OutgoingMessage;
+ const effectResponse = { body: 'test' } as HttpEffectResponse;
+ const sender = jest.fn();
+
+ const effect$ = r.pipe(
+ r.matchPath('/'),
+ r.matchType('GET'),
+ r.useEffect(req$ => req$.pipe(mapTo(effectResponse))),
+ );
+
+ const middleware$: HttpMiddlewareEffect = req$ => req$;
// when
- effectsCombiner.combineMiddlewares = jest.fn(() => () => of(req));
- routerFactory.factorizeRouting = jest.fn(() => []);
- routerResolver.resolveRouting = jest.fn(() => () => () => of({ body: 'test' }));
- responseHandler.handleResponse = jest.fn(() => () => () => undefined);
+ responseHandler.handleResponse = jest.fn(() => () => sender);
httpListener({
middlewares: [middleware$],
@@ -54,58 +43,62 @@ describe('Http listener', () => {
})(context)(req, res);
// then
- setTimeout(() => {
- expect(effectsCombiner.combineMiddlewares).toHaveBeenCalledWith(middleware$);
- expect(routerFactory.factorizeRouting).toHaveBeenCalledWith([effect$]);
- expect(routerResolver.resolveRouting).toHaveBeenCalled();
- expect(responseHandler.handleResponse).toHaveBeenCalled();
+ expect(sender).toHaveBeenCalledWith(effectResponse);
done();
- }, 0);
});
- test('#httpListener allows empty middlewares', () => {
+ test('#httpListener allows empty middlewares', done => {
// given
- const req = {} as IncomingMessage;
+ const req = { url: '/', method: 'GET' } as http.IncomingMessage;
+ const res = {} as http.OutgoingMessage;
+ const effectResponse = { body: 'test' } as HttpEffectResponse;
+ const sender = jest.fn();
+
+ const effect$ = r.pipe(
+ r.matchPath('/'),
+ r.matchType('GET'),
+ r.useEffect(req$ => req$.pipe(mapTo(effectResponse))),
+ );
// when
- effectsCombiner.combineMiddlewares = jest.fn(() => () => of(req));
- routerFactory.factorizeRouting = jest.fn(() => []);
+ responseHandler.handleResponse = jest.fn(() => () => sender);
httpListener({
+ middlewares: [],
effects: [effect$],
- })(context);
+ })(context)(req, res);
// then
- expect(effectsCombiner.combineMiddlewares).toHaveBeenCalledWith(...[]);
+ expect(sender).toHaveBeenCalledWith(effectResponse);
+ done();
});
test('#httpListener catches error', done => {
// given
const error = new Error('test');
- const req = {} as IncomingMessage;
- const res = {} as OutgoingMessage;
- const error$ = jest.fn(() => of({ body: 'error' }));
+ const req = { url: '/', method: 'GET' } as http.IncomingMessage;
+ const res = {} as http.OutgoingMessage;
+ const effectResponse = { body: { error: { message: 'test', status: 500 } }, status: 500 } as HttpEffectResponse;
+ const sender = jest.fn();
+
+ const effect$ = r.pipe(
+ r.matchPath('/'),
+ r.matchType('GET'),
+ r.useEffect(req$ => req$.pipe(
+ mergeMap(() => throwError(error)),
+ )),
+ );
// when
- effectsCombiner.combineMiddlewares = jest.fn(() => () => of(req));
- routerFactory.factorizeRouting = jest.fn(() => []);
- routerResolver.resolveRouting = jest.fn(() => () => () =>
- of({ body: 'test' }).pipe(switchMap(() => throwError(error)))
- );
- responseHandler.handleResponse = jest.fn(() => () => () => undefined);
+ responseHandler.handleResponse = jest.fn(() => () => sender);
httpListener({
- middlewares: [middleware$],
+ middlewares: [],
effects: [effect$],
- error$,
})(context)(req, res);
// then
- setTimeout(() => {
- expect(responseHandler.handleResponse).toHaveBeenCalledTimes(1);
- expect(error$).toHaveBeenCalled();
+ expect(sender).toHaveBeenCalledWith(effectResponse);
done();
- }, 0);
});
-
});

MODIFY
packages/core/src/listener/http.listener.ts
packages/core/src/listener/http.listener.ts
import { IncomingMessage, OutgoingMessage } from 'http';
+import { flow } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import * as R from 'fp-ts/lib/Reader';
-import { of, Subject } from 'rxjs';
+import { of, Subject, zip, OperatorFunction } from 'rxjs';
import { catchError, defaultIfEmpty, mergeMap, tap, map, publish } from 'rxjs/operators';
import { combineMiddlewares } from '../effects/effects.combiner';
import {
@@ -20,6 +21,7 @@ import { Context, lookup } from '../context/context.factory';
import { createEffectContext } from '../effects/effectsContext.factory';
import { useContext } from '../context/context.hook';
import { ServerClientToken } from '../server/server.tokens';
+import { EffectError } from '../error/error.model';
export interface HttpListenerConfig {
middlewares?: HttpMiddlewareEffect[];
@@ -51,8 +53,23 @@ export const httpListener = ({
const routing = factorizeRouting(effects);
const defaultResponse = { status: HttpStatus.NOT_FOUND } as HttpEffectResponse;
+ const errorGuard: OperatorFunction<[any, HttpRequest], HttpRequest> = flow(
+ map(result => {
+ if (result[0] instanceof Error) throw new EffectError(result[0], result[1]);
+ return result[0];
+ }),
+ );
+
+ const catchEffectError: OperatorFunction<HttpRequest, Error> = flow(
+ catchError(error => of(error)),
+ );
+
request$.pipe(
- publish(req$ => combinedMiddlewares(req$, effectContext)),
+ publish(req$ => zip(
+ combinedMiddlewares(req$, effectContext).pipe(catchEffectError),
+ req$,
+ )),
+ errorGuard,
mergeMap(req => {
return of(req).pipe(
mergeMap(resolveRouting(routing, effectContext)),
@@ -67,7 +84,15 @@ export const httpListener = ({
),
);
}),
+ ).subscribe(
+ undefined,
+ ({ req, error }: EffectError) => {
+ error$(of({ req, error }), effectContext).pipe(
+ mergeMap(out => output$(of({ req, res: out }), effectContext)),
+ tap(req.response.send),
).subscribe();
+ },
+ );
const httpServer = (req: IncomingMessage, res: OutgoingMessage) => {
const marbleReq = req as HttpRequest;

fix(core): handle gracefuly middlewares errors