refactor(http): content-related headers
MODIFY
packages/http/src/+internal/contentType.util.spec.ts
packages/http/src/+internal/contentType.util.spec.ts
+import * as fs from 'fs';
+import * as path from 'path';
import * as O from 'fp-ts/lib/Option';
-import { getContentType, getContentTypeEncoding, getContentTypeUnsafe } from './contentType.util';
+import { ContentType, getContentType, getContentTypeEncoding, getContentTypeUnsafe, getMimeType } from './contentType.util';
describe('#getContentType', () => {
test('returns "some" if headers object contains "Content-Type" header', () => {
@@ -28,6 +30,14 @@ describe('#getContentTypeEncoding', () => {
expect(getContentTypeEncoding({ 'content-type': 'application/json; charset=utf8' })).toEqual(O.some('utf8'));
});
+ test('returns "some" if provided "Content-Type" value directly with "charset" part', () => {
+ expect(getContentTypeEncoding('application/json; charset=utf8')).toEqual(O.some('utf8'));
+ });
+
+ test('returns "some" with lowercased "charset" part', () => {
+ expect(getContentTypeEncoding('application/json; charset=UTF8')).toEqual(O.some('utf8'));
+ });
+
test('returns "none" if headers object doesn\'t contain "Content-Type" header', () => {
expect(getContentTypeEncoding({})).toEqual(O.none);
});
@@ -39,3 +49,41 @@ describe('#getContentTypeEncoding', () => {
expect(getContentTypeEncoding({ 'Content-Type': 'application/json; charset=' })).toEqual(O.none);
});
});
+
+describe('#getMimeType', () => {
+ test('detects mime-type from path', () => {
+ // given
+ const body = Buffer.from('test');
+ const pathToIndex = '/test/index.html';
+
+ // when
+ const mimeType = getMimeType(body, pathToIndex);
+
+ // then
+ expect(mimeType).toEqual(ContentType.TEXT_HTML);
+ });
+
+ test('detects mime-type from buffer', () => {
+ // given
+ const body = fs.readFileSync(path.resolve(__dirname, '../../../../assets/img', 'logo.png'));
+ const pathToIndex = '/test/index.html';
+
+ // when
+ const mimeType = getMimeType(body, pathToIndex);
+
+ // then
+ expect(mimeType).toEqual(ContentType.IMAGE_PNG);
+ });
+
+ test('returns null if cannot guess mime-type ', () => {
+ // given
+ const body = Buffer.from('test');
+ const pathToIndex = '/test/index';
+
+ // when
+ const mimeType = getMimeType(body, pathToIndex);
+
+ // then
+ expect(mimeType).toEqual(null);
+ });
+});

MODIFY
packages/http/src/+internal/contentType.util.ts
packages/http/src/+internal/contentType.util.ts
+import * as fileType from 'file-type';
+import * as mime from 'mime';
import * as O from 'fp-ts/lib/Option';
import * as A from 'fp-ts/lib/Array';
import { constant, pipe } from 'fp-ts/lib/function';
+import { isString } from '@marblejs/core/dist/+internal/utils';
import { HttpHeaders } from '../http.interface';
export enum ContentType {
@@ -39,26 +42,83 @@ export enum ContentType {
MULTIPART_FORM_DATA = 'multipart/form-data',
}
-export const getContentType = (headers: HttpHeaders): O.Option<string> =>
+/**
+ * Get header value for given key from provided headers object
+ *
+ * @param key header key
+ * @since 4.0.0
+ */
+export const getHeaderValue = <T extends string = string>(key: string) => (headers: HttpHeaders): O.Option<T> =>
pipe(
- O.fromNullable(headers['content-type'] ?? headers['Content-Type']),
+ O.fromNullable(headers[key] ?? headers[key.toLowerCase()]),
O.chain(value => Array.isArray(value)
- ? A.head(value)
- : O.some(String(value))),
+ ? A.head(value) as O.Option<T>
+ : O.some(String(value)) as O.Option<T>),
);
+/**
+ * Get `Content-Type` header value from provided headers object
+ *
+ * @see getHeaderValue
+ * @since 4.0.0
+ */
+export const getContentType = getHeaderValue('Content-Type');
+
+/**
+ * Get `Content-Length` header value from provided headers object
+ *
+ * @see getHeaderValue
+ * @since 4.0.0
+ */
+export const getContentLength = getHeaderValue('Content-Length');
+
+/**
+ * Get **UNSAFE** `Content-Type` header value from provided headers object
+ *
+ * @see getHeaderValue
+ * @since 4.0.0
+ */
export const getContentTypeUnsafe = (headers: HttpHeaders): string =>
pipe(
getContentType(headers),
O.getOrElse(constant('')),
);
-export const getContentTypeEncoding = (headers: HttpHeaders): O.Option<string> =>
+
+/**
+ * Get `Content-Type` charset (encoding) value, either from provided headers object or header value directly
+ *
+ * @since 4.0.0
+ */
+export const getContentTypeEncoding = (headersOrContentType: HttpHeaders | string): O.Option<string> =>
pipe(
- getContentType(headers),
+ isString(headersOrContentType) ? O.some(headersOrContentType) : getContentType(headersOrContentType),
O.chain(cType => O.fromNullable(/charset=([^()<>@,;:\"/[\]?.=\s]*)/i.exec(cType))),
O.chain(res => O.fromNullable(res[1] || null)),
+ O.map(encoding => encoding.toLowerCase()),
+ );
+
+/**
+ * Guess mime type for given combination of request body and path
+ *
+ * @param body request body
+ * @param path request url
+ * @returns `string | null`
+ * @since 1.0.0
+ */
+export const getMimeType = (body: any, path: string): string | null =>
+ pipe(
+ O.fromNullable(Buffer.isBuffer(body) ? fileType(body) : null),
+ O.map(mimeFromBuffer => mimeFromBuffer.mime),
+ O.getOrElse(() => mime.getType(path)),
);
+/**
+ * Checks whether given Content-Type header value is of `json` type
+ *
+ * @param headerValue
+ * @returns boolean
+ * @since 1.0.0
+ */
export const isJsonContentType = (headerValue: string): boolean =>
headerValue.includes('json');

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

MODIFY
packages/http/src/response/http.responseHeaders.factory.ts
packages/http/src/response/http.responseHeaders.factory.ts
+import * as O from 'fp-ts/lib/Option';
import * as IO from 'fp-ts/lib/IO';
-import { pipe } from 'fp-ts/lib/function';
+import { constant, pipe } from 'fp-ts/lib/function';
import { isStream, isEmpty, getEnvConfigOrElseAsBoolean, isString } from '@marblejs/core/dist/+internal/utils';
-import { ContentType } from '../+internal/contentType.util';
+import { ContentType, getContentLength, getContentType, getMimeType } 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;
@@ -29,7 +29,7 @@ export const MARBLE_HTTP_HEADERS_NORMALIZATION_ENV_KEY = 'MARBLE_HTTP_HEADERS_NO
const useHttpHeadersNormalization = getEnvConfigOrElseAsBoolean(MARBLE_HTTP_HEADERS_NORMALIZATION_ENV_KEY, true);
-const getContentLengthHeader = (response: HttpResponseLike): HttpHeaders => {
+const provideContentLengthHeader = (response: HttpResponseLike): HttpHeaders => {
if (isStream(response.body)) return {};
const contentLength = isEmpty(response.body) ? 0 : Buffer.byteLength(
@@ -38,15 +38,45 @@ const getContentLengthHeader = (response: HttpResponseLike): HttpHeaders => {
: JSON.stringify(response.body)
);
- return { 'Content-Length': contentLength };
+ return {
+ 'Content-Length': contentLength,
};
+};
+
+export const provideContentTypeHeader = (response: HttpResponseLike): HttpHeaders => {
+ const contentType = response.status < 400
+ ? getMimeType(response.body, response.path)
+ : DEFAULT_HEADERS['Content-Type'];
+
+ return {
+ 'Content-Type': contentType ?? DEFAULT_HEADERS['Content-Type'],
+ };
+};
+
+export const factorizeHeaders = (response: HttpResponseLike) => (providedHeaders?: HttpHeaders): IO.IO<HttpHeaders> => {
+ const defaultContentTypeHeaders = pipe(
+ O.fromNullable(providedHeaders),
+ O.chain(getContentType),
+ O.fold(
+ () => ({
+ ...provideContentTypeHeader(response),
+ ...provideContentLengthHeader(response),
+ }),
+ () => pipe(
+ O.fromNullable(providedHeaders),
+ O.chain(getContentLength),
+ O.fold(
+ () => ({ ...provideContentLengthHeader(response) }),
+ constant({}),
+ )
+ )),
+ );
+
-export const factorizeHeaders = (response: HttpResponseLike) => (headers?: HttpHeaders): IO.IO<HttpHeaders> => {
const mergedHeaders = {
...DEFAULT_HEADERS,
- ...contentTypeFactory(response),
- ...getContentLengthHeader(response),
- ...headers,
+ ...defaultContentTypeHeaders,
+ ...providedHeaders,
};
return pipe(

DELETE
packages/http/src/response/specs/http.responseContentType.factory.spec.ts
-import * as fs from 'fs';
-import * as path from 'path';
-import { ContentType } from '../../+internal/contentType.util';
-import { DEFAULT_CONTENT_TYPE, contentTypeFactory, getMimeType } from '../http.responseContentType.factory';
-
-describe('Response content-type factory', () => {
-
- it('#getMimeType detects mime-type from path', () => {
- // given
- const body = Buffer.from('test');
- const pathToIndex = '/test/index.html';
-
- // when
- const mimeType = getMimeType(body, pathToIndex);
-
- // then
- expect(mimeType).toEqual(ContentType.TEXT_HTML);
- });
-
- it('#getMimeType detects mime-type from buffer', () => {
- // given
- const body = fs.readFileSync(path.resolve(__dirname, '../../../../../assets/img', 'logo.png'));
- const pathToIndex = '/test/index.html';
-
- // when
- const mimeType = getMimeType(body, pathToIndex);
-
- // then
- expect(mimeType).toEqual(ContentType.IMAGE_PNG);
- });
-
- it('#getMimeType returns DEFAULT_CONTENT_TYPE', () => {
- // given
- const body = Buffer.from('test');
- const pathToIndex = '/test/index';
-
- // when
- const mimeType = getMimeType(body, pathToIndex);
-
- // 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 });
- });
-
-});

MODIFY
packages/testing/src/testBed/http/http.testBed.request.ts
packages/testing/src/testBed/http/http.testBed.request.ts
import { HttpMethod, HttpHeaders } from '@marblejs/http';
import * as O from 'fp-ts/lib/Option';
-import * as A from 'fp-ts/lib/Array';
import { pipe } from 'fp-ts/lib/function';
-import { getMimeType } from '@marblejs/http/dist/response/http.responseContentType.factory';
+import { ContentType, getHeaderValue, getMimeType } from '@marblejs/http/dist/+internal/contentType.util';
import { HttpTestBedRequest, WithBodyApplied } from './http.testBed.request.interface';
import { createTestingRequestHeader } from './http.testBed.util';
@@ -35,15 +34,10 @@ export const withBody = <T>(body: T) => <U extends 'POST' | 'PUT' | 'PATCH'>(req
O.map(() => ({ ...req, body })),
O.getOrElse(() => pipe(
getMimeType(body, req.path),
- type => withHeaders({ 'Content-Type': type })(req),
+ type => withHeaders({ 'Content-Type': type ?? ContentType.APPLICATION_JSON })(req),
req => ({ ...req, body }),
))
);
export const getHeader = <T extends string = string>(key: string) => (req: HttpTestBedRequest<any>): O.Option<T> =>
- pipe(
- O.fromNullable(req.headers[key]),
- O.chain(header => Array.isArray(header)
- ? A.head(header) as O.Option<T>
- : O.some(header) as O.Option<T>),
- );
+ getHeaderValue<T>(key)(req.headers);

refactor(http): content-related headers