feat(http): normalize HTTP headers
MODIFY
packages/core/src/+internal/utils/any.util.ts
packages/core/src/+internal/utils/any.util.ts
@@ -49,3 +49,8 @@ export const pipeFromArray = <T, R>(fns: Array<UnaryFunction<T, R>>): UnaryFunct
return fns.reduce((prev: any, fn: UnaryFunction<T, R>) => fn(prev), input as any);
};
};
+
+export const isEmpty = (obj: Record<string, unknown>): boolean =>
+ isNullable(obj)
+ ? true
+ : Object.keys(obj).length === 0;

MODIFY
packages/http/src/+internal/header.util.spec.ts
packages/http/src/+internal/header.util.spec.ts
import { createServer, Server } from 'http';
-import { getHeaderValueHead } from './header.util';
+import { getHeaderValueHead, normalizeHeaders } from './header.util';
import { closeServer, getServerAddress } from './server.util';
import { createHttpRequest } from './testing.util';
@@ -28,3 +28,15 @@ test('#getServerAddress', async () => {
await closeServer(server)();
});
+
+test('#normalizeHeaders', () => {
+ expect(normalizeHeaders({
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ABC123',
+ 'x-test-1': 'test-123',
+ })).toEqual({
+ 'content-type': 'application/json',
+ 'authorization': 'Bearer ABC123',
+ 'x-test-1': 'test-123',
+ });
+});

MODIFY
packages/http/src/+internal/header.util.ts
packages/http/src/+internal/header.util.ts
@@ -2,7 +2,7 @@ import { pipe } from 'fp-ts/lib/function';
import { toUndefined } from 'fp-ts/lib/Option';
import { TESTING_REQUEST_ID_HEADER } from '@marblejs/core/dist/+internal/testing';
import { getHead } from '@marblejs/core/dist/+internal/utils';
-import { HttpRequest } from '../http.interface';
+import { HttpHeaders, HttpRequest } from '../http.interface';
type GetHeaderValue<Value extends string = string> = (req: HttpRequest) => Value | undefined;
@@ -14,5 +14,11 @@ export const getHeaderValueHead = (key: string): GetHeaderValue => req => {
: header;
};
+export const normalizeHeaders = (headers: HttpHeaders): HttpHeaders =>
+ pipe(
+ Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
+ Object.fromEntries,
+ );
+
export const getTestingRequestIdHeader: GetHeaderValue =
getHeaderValueHead(TESTING_REQUEST_ID_HEADER);

MODIFY
packages/http/src/response/http.responseHandler.ts
packages/http/src/response/http.responseHandler.ts
@@ -9,7 +9,7 @@ import { HttpEffectResponse } from '../effects/http.effects.interface';
import { HttpRequest, HttpResponse, HttpStatus } from '../http.interface';
import { HttpRequestMetadataStorageToken } from '../server/internal-dependencies/httpRequestMetadataStorage.reader';
import { getTestingRequestIdHeader } from '../+internal/header.util';
-import { headersFactory } from './http.responseHeaders.factory';
+import { factorizeHeaders } from './http.responseHeaders.factory';
import { bodyFactory } from './http.responseBody.factory';
export const handleResponse = (ask: ContextProvider) => (res: HttpResponse) => (req: HttpRequest) => (effectResponse: HttpEffectResponse) => {
@@ -18,8 +18,8 @@ export const handleResponse = (ask: ContextProvider) => (res: HttpResponse) => (
const status = effectResponse.status || HttpStatus.OK;
const path = url.parse(req.url).pathname || '';
- const headersFactoryWithData = headersFactory({ body: effectResponse.body, path, status });
- const headers = headersFactoryWithData(effectResponse.headers);
+ const headersFactoryWithData = factorizeHeaders({ body: effectResponse.body, path, status });
+ const headers = headersFactoryWithData(effectResponse.headers)(); // @TODO: refactor `#handleResponse` function
const bodyFactoryWithHeaders = bodyFactory(headers);
const body = bodyFactoryWithHeaders(effectResponse.body);
@@ -37,10 +37,6 @@ export const handleResponse = (ask: ContextProvider) => (res: HttpResponse) => (
res.writeHead(status, headers);
body.pipe(res);
} else {
- if (body) {
- res.setHeader('Content-Length', Buffer.byteLength(body));
- }
-
res.writeHead(status, headers);
res.end(body);
}

MODIFY
packages/http/src/response/http.responseHeaders.factory.ts
packages/http/src/response/http.responseHeaders.factory.ts
+import * as IO from 'fp-ts/lib/IO';
+import { pipe } from 'fp-ts/lib/function';
+import { isStream, isEmpty, getEnvConfigOrElseAsBoolean, isString } from '@marblejs/core/dist/+internal/utils';
+import { ContentType } from '../+internal/contentType.util';
+import { normalizeHeaders } from '../+internal/header.util';
import { HttpHeaders, HttpStatus } from '../http.interface';
import { contentTypeFactory } from './http.responseContentType.factory';
+interface HttpResponseLike {
+ body: any;
+ path: string;
+ status: HttpStatus;
+}
+
export const DEFAULT_HEADERS = {
- 'Content-Type': 'application/json',
+ 'Content-Type': ContentType.APPLICATION_JSON,
'X-Content-Type-Options': 'nosniff',
};
-export const headersFactory = (data: {
- body: any;
- path: string;
- status: HttpStatus;
-}) => (headers?: HttpHeaders) => ({
+/**
+ * Flag to indicate whether we should prevent converting (normalizing) all headers to lower case.
+ * This flag was introduced to prevent breaking changes, for more details see:
+ * https://github.com/marblejs/marble/issues/311
+ *
+ * Flag will be removed in the next major version,
+ * where all headers are normalized by default.
+ */
+export const MARBLE_HTTP_HEADERS_NORMALIZATION_ENV_KEY = 'MARBLE_HTTP_HEADERS_NORMALIZATION';
+
+const useHttpHeadersNormalization = getEnvConfigOrElseAsBoolean(MARBLE_HTTP_HEADERS_NORMALIZATION_ENV_KEY, true);
+
+const getContentLengthHeader = (response: HttpResponseLike): HttpHeaders => {
+ if (isStream(response.body)) return {};
+
+ const contentLength = isEmpty(response.body) ? 0 : Buffer.byteLength(
+ isString(response.body) || Buffer.isBuffer(response.body)
+ ? response.body
+ : JSON.stringify(response.body)
+ );
+
+ return { 'Content-Length': contentLength };
+};
+
+export const factorizeHeaders = (response: HttpResponseLike) => (headers?: HttpHeaders): IO.IO<HttpHeaders> => {
+ const mergedHeaders = {
...DEFAULT_HEADERS,
- ...contentTypeFactory(data),
+ ...contentTypeFactory(response),
+ ...getContentLengthHeader(response),
...headers,
-});
+ };
+
+ return pipe(
+ useHttpHeadersNormalization,
+ IO.map(shouldNormalize => shouldNormalize ? normalizeHeaders(mergedHeaders) : mergedHeaders),
+ );
+};

MODIFY
packages/http/src/response/specs/http.responseHandler.spec.ts
packages/http/src/response/specs/http.responseHandler.spec.ts
@@ -2,7 +2,6 @@ import * as path from 'path';
import * as fs from 'fs';
import { HttpResponse, HttpStatus } from '../../http.interface';
import { handleResponse } from '../http.responseHandler';
-import { DEFAULT_HEADERS } from '../http.responseHeaders.factory';
import { createMockEffectContext, createHttpRequest, createHttpResponse } from '../../+internal/testing.util';
import { ContentType } from '../../+internal/contentType.util';
@@ -21,24 +20,40 @@ describe('Response handler', () => {
test('sets provided status', () => {
handle({ status: HttpStatus.FORBIDDEN });
- expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.FORBIDDEN, DEFAULT_HEADERS);
+ expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.FORBIDDEN, {
+ 'content-length': 0,
+ 'content-type': 'application/json',
+ 'x-content-type-options': 'nosniff',
+ });
});
test('sets status OK if is not explicitly set', () => {
handle({});
- expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.OK, DEFAULT_HEADERS);
+ expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.OK, {
+ 'content-length': 0,
+ 'content-type': 'application/json',
+ 'x-content-type-options': 'nosniff',
+ });
});
test('sets default headers if are not explicitly set', () => {
handle({});
- expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.OK, DEFAULT_HEADERS);
+ expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.OK, {
+ 'content-length': 0,
+ 'content-type': 'application/json',
+ 'x-content-type-options': 'nosniff',
+ });
});
test('sets provided headers', () => {
const CUSTOM_HEADERS = { 'Content-Encoding': 'gzip' };
- handle({ headers: CUSTOM_HEADERS, body: {} });
- expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.OK, { ...DEFAULT_HEADERS, ...CUSTOM_HEADERS });
- expect(response.setHeader).toHaveBeenCalledWith('Content-Length', 2);
+ handle({ headers: CUSTOM_HEADERS, body: { test: 'test' } });
+ expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.OK, {
+ 'content-encoding': 'gzip',
+ 'content-length': 15,
+ 'content-type': 'application/json',
+ 'x-content-type-options': 'nosniff',
+ });
});
test('sets unefined response if body is not provided', () => {

MODIFY
packages/http/src/response/specs/http.responseHeaders.factory.spec.ts
packages/http/src/response/specs/http.responseHeaders.factory.spec.ts
-import { DEFAULT_HEADERS, headersFactory } from '../http.responseHeaders.factory';
+import { normalizeHeaders } from '../../+internal/header.util';
+import { DEFAULT_HEADERS, factorizeHeaders } from '../http.responseHeaders.factory';
-describe('Response headers factory', () => {
+describe('#factorizeHeaders', () => {
+ test.each([ null, undefined, {}, '' ])(`returns normalized default headers for "%s" body`, (body: any) => {
+ const REQUEST = { status: 400, path: '/', body };
- const FACTORY_DATA = { status: 400, body: '', path: '/' };
+ expect(factorizeHeaders(REQUEST)()()).toEqual(normalizeHeaders({
+ ...DEFAULT_HEADERS,
+ 'content-length': 0,
+ }));
+ });
+
+ test('returns normalized default headers if custom are not provided', () => {
+ const REQUEST = { status: 400, path: '/', body: 'A' };
+
+ expect(factorizeHeaders(REQUEST)()()).toEqual(normalizeHeaders({
+ ...DEFAULT_HEADERS,
+ 'content-length': 1,
+ }));
+ });
+
+ test('returns normalized merged custom headers if are provided', () => {
+ const CUSTOM_HEADERS = { 'Content-Encoding': 'gzip' };
+ const REQUEST = { status: 400, path: '/', body: 'A' };
- it('returns default headers if custom are not provided', () => {
- expect(headersFactory(FACTORY_DATA)()).toEqual(DEFAULT_HEADERS);
+ expect(factorizeHeaders(REQUEST)(CUSTOM_HEADERS)()).toEqual(normalizeHeaders({
+ ...DEFAULT_HEADERS,
+ ...CUSTOM_HEADERS,
+ 'content-length': 1,
+ }));
});
- it('returns custom headers if are provided', () => {
+
+ test('returns normalized headers with calculated "content-length"', () => {
const CUSTOM_HEADERS = { 'Content-Encoding': 'gzip' };
- expect(headersFactory(FACTORY_DATA)(CUSTOM_HEADERS)).toEqual({ ...DEFAULT_HEADERS, ...CUSTOM_HEADERS });
+ const REQUEST = { status: 400, path: '/', body: '123' };
+
+ expect(factorizeHeaders(REQUEST)(CUSTOM_HEADERS)()).toEqual(normalizeHeaders({
+ ...DEFAULT_HEADERS,
+ ...CUSTOM_HEADERS,
+ 'content-length': 3,
+ }));
});
- it('returns not duplicated headers', () => {
- const CUSTOM_HEADERS = { 'Content-Type': 'application/json' };
- expect(headersFactory(FACTORY_DATA)(CUSTOM_HEADERS)).toEqual({ ...DEFAULT_HEADERS });
+ test('returns normalized headers with calculated "content-length" when body is an object', () => {
+ const CUSTOM_HEADERS = { 'Content-Encoding': 'gzip' };
+ const REQUEST = { status: 400, path: '/', body: { test: 'TEST' } };
+
+ expect(factorizeHeaders(REQUEST)(CUSTOM_HEADERS)()).toEqual(normalizeHeaders({
+ ...DEFAULT_HEADERS,
+ ...CUSTOM_HEADERS,
+ 'content-length': 15,
+ }));
});
+ test('returns normalized, not duplicated headers', () => {
+ const CUSTOM_HEADERS = { 'content-type': 'application/json' };
+ const REQUEST = { status: 400, path: '/', body: 'A' };
+
+ expect(factorizeHeaders(REQUEST)(CUSTOM_HEADERS)()).toEqual(normalizeHeaders({
+ ...DEFAULT_HEADERS,
+ 'content-length': 1,
+ }));
+ });
});

feat(http): normalize HTTP headers