feat(core): handle response content-type
ADD
docs/assets/index.html
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" />
+ <title>Test HTML</title>
+</head>
+<body>
+ <h1>Test</h1>
+</body>
+</html>

MODIFY
packages/core/src/http.listener.ts
packages/core/src/http.listener.ts
import { Subject, of } from 'rxjs';
-import { catchError, defaultIfEmpty, switchMap, mergeMap, tap } from 'rxjs/operators';
-import { combineEffects, combineMiddlewareEffects } from './effects/effects.combiner';
-import { Effect, EffectResponse, GroupedEffects } from './effects/effects.interface';
+import {
+ catchError,
+ defaultIfEmpty,
+ mergeMap,
+ switchMap,
+ tap,
+} from 'rxjs/operators';
+import {
+ combineEffects,
+ combineMiddlewareEffects,
+} from './effects/effects.combiner';
+import {
+ Effect,
+ EffectResponse,
+ GroupedEffects,
+} from './effects/effects.interface';
import { Http, HttpRequest, HttpResponse, HttpStatus } from './http.interface';
import { getErrorMiddleware } from './middlewares/error.middleware';
import { handleResponse } from './response/response.handler';
type HttpListenerConfig = {
- middlewares?: Effect<HttpRequest>[],
- effects: (Effect | GroupedEffects)[],
- errorMiddleware?: Effect<EffectResponse, Error>,
+ middlewares?: Effect<HttpRequest>[];
+ effects: (Effect | GroupedEffects)[];
+ errorMiddleware?: Effect<EffectResponse, Error>;
};
-export const httpListener = ({ middlewares = [], effects, errorMiddleware }: HttpListenerConfig) => {
+export const httpListener = ({
+ middlewares = [],
+ effects,
+ errorMiddleware,
+}: HttpListenerConfig) => {
const request$ = new Subject<Http>();
- const effect$ = request$
- .pipe(
+ const effect$ = request$.pipe(
mergeMap(({ req, res }) =>
- combineMiddlewareEffects(middlewares)(res)(req)
- .pipe(
+ combineMiddlewareEffects(middlewares)(res)(req).pipe(
switchMap(combineEffects(effects)(res)),
defaultIfEmpty({ status: HttpStatus.NOT_FOUND } as EffectResponse),
- tap(handleResponse(res)),
+ tap(handleResponse(res)(req)),
catchError(error =>
- getErrorMiddleware(errorMiddleware)(of(req), res, error)
- .pipe(
- tap(handleResponse(res)),
+ getErrorMiddleware(errorMiddleware)(of(req), res, error).pipe(
+ tap(handleResponse(res)(req)),
+ ),
+ ),
+ ),
),
- )
- )
- )
);
effect$.subscribe();

MODIFY
packages/core/src/response/response.handler.spec.ts
packages/core/src/response/response.handler.spec.ts
-import { HttpResponse, HttpStatus } from '../http.interface';
+import { HttpRequest, HttpResponse, HttpStatus } from '../http.interface';
import { handleResponse } from './response.handler';
import { DEFAULT_HEADERS } from './responseHeaders.factory';
const RESPONSE = {
writeHead: jest.fn(),
+ setHeader: jest.fn(),
end: jest.fn(),
} as any as HttpResponse;
+const REQUEST = {
+ url: '/',
+} as any as HttpRequest;
+
describe('Response handler', () => {
- const handle = handleResponse(RESPONSE);
+ const handle = handleResponse(RESPONSE)(REQUEST);
it('sets provided status', () => {
handle({ status: HttpStatus.FORBIDDEN });
@@ -28,8 +33,9 @@ describe('Response handler', () => {
it('sets provided headers', () => {
const CUSTOM_HEADERS = { 'Content-Encoding': 'gzip' };
- handle({ headers: CUSTOM_HEADERS });
+ handle({ headers: CUSTOM_HEADERS, body: {} });
expect(RESPONSE.writeHead).toHaveBeenCalledWith(HttpStatus.OK, { ...DEFAULT_HEADERS, ...CUSTOM_HEADERS });
+ expect(RESPONSE.setHeader).toHaveBeenCalledWith('Content-Length', 2);
});
it('sets unefined response if body is not provided', () => {

MODIFY
packages/core/src/response/response.handler.ts
packages/core/src/response/response.handler.ts
import { EffectResponse } from '../effects/effects.interface';
-import { HttpResponse, HttpStatus } from '../http.interface';
+import { HttpRequest, HttpResponse, HttpStatus } from '../http.interface';
+import { bodyFactory } from './responseBody.factory';
import { headersFactory } from './responseHeaders.factory';
-export const handleResponse = (res: HttpResponse) => (effect: EffectResponse) => {
+export const handleResponse = (res: HttpResponse) => (req: HttpRequest) => (
+ effect: EffectResponse,
+) => {
const status = effect.status || HttpStatus.OK;
- res.writeHead(status, headersFactory(effect.headers));
- res.end(JSON.stringify(effect.body));
+ const headers = headersFactory({ body: effect.body, path: req.url!, status })(
+ effect.headers,
+ );
+ const body = bodyFactory(headers)(effect.body);
+
+ if (body) {
+ res.setHeader('Content-Length', Buffer.byteLength(body));
+ }
+
+ res.writeHead(status, headers);
+ res.end(body);
};

ADD
packages/core/src/response/responseBody.factory.spec.ts
+import { ContentType } from '../util/contentType.util';
+import { bodyFactory } from './responseBody.factory';
+
+describe('Response body factory', () => {
+
+ it('#bodyFactory factorizes body for JSON', () => {
+ // given
+ const headers = { 'Content-Type': ContentType.APPLICATION_JSON };
+ const body = { test: 'test' };
+
+ // when
+ const factorizedBody = bodyFactory(headers)(body);
+
+ // then
+ expect(factorizedBody).toEqual(JSON.stringify(body));
+ });
+
+ it(`#bodyFactory doesn't factorize body`, () => {
+ // given
+ const headers = { 'Content-Type': ContentType.AUDIO };
+ const body = '1234';
+
+ // when
+ const factorizedBody = bodyFactory(headers)(body);
+
+ // then
+ expect(factorizedBody).toEqual('1234');
+ });
+
+});

ADD
packages/core/src/response/responseBody.factory.ts
+import { HttpHeaders } from '../http.interface';
+import { ContentType } from '../util/contentType.util';
+
+export const bodyFactory = (headers: HttpHeaders) => (body: any) => {
+ switch (headers['Content-Type']) {
+ case ContentType.APPLICATION_JSON:
+ return JSON.stringify(body);
+ default:
+ return body;
+ }
+};

ADD
packages/core/src/response/responseContentType.factory.spec.ts
+import * as fs from 'fs';
+import { ContentType } from '../util/contentType.util';
+import { DEFAULT_CONTENT_TYPE, contentTypeFactory, getMimeType } from './responseContentType.factory';
+
+describe('Response content-type factory', () => {
+
+ it('#getMimeType detects mime-type from path', () => {
+ // given
+ const body = new Buffer('test');
+ const path = '/test/index.html';
+
+ // when
+ const mimeType = getMimeType(body, path);
+
+ // then
+ expect(mimeType).toEqual(ContentType.TEXT_HTML);
+ });
+
+ it('#getMimeType detects mime-type from buffer', () => {
+ // given
+ const body = fs.readFileSync(__dirname + '/../../../../docs/assets/logo.png');
+ const path = '/test/index.html';
+
+ // when
+ const mimeType = getMimeType(body, path);
+
+ // then
+ expect(mimeType).toEqual(ContentType.IMAGE_PNG);
+ });
+
+ it('#getMimeType returns DEFAULT_CONTENT_TYPE', () => {
+ // given
+ const body = new Buffer('test');
+ const path = '/test/index';
+
+ // when
+ const mimeType = getMimeType(body, path);
+
+ // then
+ expect(mimeType).toEqual(DEFAULT_CONTENT_TYPE);
+ });
+
+ it('#contentTypeFactory returns DEFAULT_CONTENT_TYPE', () => {
+ // given
+ const data = { body: {}, path: '/index.html', status: 400 };
+
+ // when
+ const contentType = contentTypeFactory(data);
+
+ // then
+ expect(contentType).toEqual({ 'Content-Type': DEFAULT_CONTENT_TYPE });
+ });
+
+});

ADD
packages/core/src/response/responseContentType.factory.ts
+import * as fileType from 'file-type';
+import * as mime from 'mime';
+import { HttpStatus } from '../http.interface';
+import { ContentType } from '../util/contentType.util';
+
+export const DEFAULT_CONTENT_TYPE = ContentType.APPLICATION_JSON;
+
+export const getMimeType = (body: any, path: string) => {
+ const mimeFromBuffer = Buffer.isBuffer(body) && fileType(body);
+
+ if (mimeFromBuffer) {
+ return mimeFromBuffer.mime;
+ }
+
+ return mime.getType(path) || DEFAULT_CONTENT_TYPE;
+};
+
+export const contentTypeFactory = (data: {
+ body: any;
+ path: string;
+ status: HttpStatus;
+}) => ({
+ 'Content-Type':
+ data.status === 200
+ ? getMimeType(data.body, data.path)
+ : DEFAULT_CONTENT_TYPE,
+});

MODIFY
packages/core/src/response/responseHeaders.factory.spec.ts
packages/core/src/response/responseHeaders.factory.spec.ts
-import { headersFactory } from './responseHeaders.factory';
+import { DEFAULT_HEADERS, headersFactory } from './responseHeaders.factory';
describe('Response headers factory', () => {
+ const FACTORY_DATA = { status: 400, body: '', path: '/' };
+
it('returns default headers if custom are not provided', () => {
- const DEFAULT_HEADERS = { 'Content-Type': 'application/json' };
- expect(headersFactory()).toEqual(DEFAULT_HEADERS);
+ expect(headersFactory(FACTORY_DATA)()).toEqual(DEFAULT_HEADERS);
});
it('returns custom headers if are provided', () => {
- const DEFAULT_HEADERS = { 'Content-Type': 'application/json' };
const CUSTOM_HEADERS = { 'Content-Encoding': 'gzip' };
- expect(headersFactory(CUSTOM_HEADERS)).toEqual({ ...DEFAULT_HEADERS, ...CUSTOM_HEADERS });
+ expect(headersFactory(FACTORY_DATA)(CUSTOM_HEADERS)).toEqual({ ...DEFAULT_HEADERS, ...CUSTOM_HEADERS });
});
it('returns not duplicated headers', () => {
- const DEFAULT_HEADERS = { 'Content-Type': 'application/json' };
const CUSTOM_HEADERS = { 'Content-Type': 'application/json' };
- expect(headersFactory(CUSTOM_HEADERS)).toEqual({ ...DEFAULT_HEADERS });
+ expect(headersFactory(FACTORY_DATA)(CUSTOM_HEADERS)).toEqual({ ...DEFAULT_HEADERS });
});
});

MODIFY
packages/core/src/response/responseHeaders.factory.ts
packages/core/src/response/responseHeaders.factory.ts
-import { HttpHeaders } from '../http.interface';
-import { ContentType } from '../util/contentType.util';
+import { HttpHeaders, HttpStatus } from '../http.interface';
+import { contentTypeFactory } from './responseContentType.factory';
export const DEFAULT_HEADERS = {
- 'Content-Type': ContentType.APPLICATION_JSON,
+ 'Content-Type': 'application/json',
+ 'X-Content-Type-Options': 'nosniff',
};
-export const headersFactory = (headers?: HttpHeaders) => ({
+export const headersFactory = (data: {
+ body: any;
+ path: string;
+ status: HttpStatus;
+}) => (headers?: HttpHeaders) => ({
...DEFAULT_HEADERS,
+ ...contentTypeFactory(data),
...headers,
});

feat(core): handle response content-type