feat(core): request metadata storage
MODIFY
packages/core/src/+internal/testing/metadata.helper.ts
packages/core/src/+internal/testing/metadata.helper.ts
-import { HttpHeaders, HttpRequest, HttpResponse } from '../../http.interface';
import { JSONSchema7 } from 'json-schema';
+import { getHeaderValue } from '../utils/http.util';
export interface TestingMetadata {
path?: string;
@@ -9,24 +9,8 @@ export interface TestingMetadata {
query?: JSONSchema7;
}
-export const TESTING_HEADER = 'X-Marble-Testing';
-
-export const stripMetadataHeader = (headers: HttpHeaders): TestingMetadata => {
- const key = TESTING_HEADER.toLowerCase();
- const header = headers[key];
- delete headers[key];
- return JSON.parse(decodeURIComponent(header as string));
-};
+export const TESTING_REQUEST_ID_HEADER = 'X-Testing-Request-Id';
export const isTestingMetadataOn = () => process.env.MARBLE_TESTING_METADATA_ON === 'true';
-export const setMetadataHeader = (response: HttpResponse, meta: TestingMetadata) =>
- response.setHeader(TESTING_HEADER, encodeURIComponent(JSON.stringify(meta)));
-
-export const applyMetadata = (req: HttpRequest, res: HttpResponse) => {
- if (!isTestingMetadataOn()) {
- return;
- }
-
- setMetadataHeader(res, { ...req.meta });
-};
+export const getTestingRequestIdHeader = getHeaderValue(TESTING_REQUEST_ID_HEADER);

MODIFY
packages/core/src/+internal/utils/array.util.ts
packages/core/src/+internal/utils/array.util.ts
@@ -14,3 +14,6 @@ export const filterArray = <T>(f: (v: T) => boolean) => (array: T[]) =>
export const mapArray = <T, R>(f: (v: T) => R) => (array: T[]) =>
array.map(f);
+
+export const insertIf = <T>(condition: boolean, ...elements: T[]) =>
+ condition ? elements : [];

ADD
packages/core/src/+internal/utils/http.util.ts
+import { HttpRequest } from '../../http.interface';
+
+export const getHeaderValue = <T extends string = string>(key: string) => (req: HttpRequest): T | undefined => {
+ const header = req.headers[key.toLowerCase()];
+ return Array.isArray(header)
+ ? header[0] as T
+ : header as T;
+};

MODIFY
packages/core/src/+internal/utils/index.ts
packages/core/src/+internal/utils/index.ts
@@ -3,3 +3,4 @@ export * from './string.util';
export * from './error.util';
export * from './any.util';
export * from './stream.util';
+export * from './http.util';

MODIFY
packages/core/src/http.interface.ts
packages/core/src/http.interface.ts
@@ -3,6 +3,8 @@ import * as https from 'https';
import { Observable } from 'rxjs';
import { HttpEffectResponse } from './effects/http-effects.interface';
+export type HttpRequestMetadata = Record<string, any>;
+
export interface HttpRequest<
TBody = unknown,
TParams = unknown,
@@ -13,7 +15,7 @@ export interface HttpRequest<
body: TBody;
params: TParams;
query: TQuery;
- meta?: Record<string, any>;
+ meta?: HttpRequestMetadata;
response: HttpResponse;
[key: string]: any;
}

MODIFY
packages/core/src/listener/http.listener.spec.ts
packages/core/src/listener/http.listener.spec.ts
@@ -35,7 +35,7 @@ describe('Http listener', () => {
const middleware$: HttpMiddlewareEffect = req$ => req$;
// when
- responseHandler.handleResponse = jest.fn(() => () => sender);
+ responseHandler.handleResponse = jest.fn(() => () => () => sender);
httpListener({
middlewares: [middleware$],
@@ -61,7 +61,7 @@ describe('Http listener', () => {
);
// when
- responseHandler.handleResponse = jest.fn(() => () => sender);
+ responseHandler.handleResponse = jest.fn(() => () => () => sender);
httpListener({
middlewares: [],
@@ -90,7 +90,7 @@ describe('Http listener', () => {
);
// when
- responseHandler.handleResponse = jest.fn(() => () => sender);
+ responseHandler.handleResponse = jest.fn(() => () => () => sender);
httpListener({
middlewares: [],

MODIFY
packages/core/src/listener/http.listener.ts
packages/core/src/listener/http.listener.ts
@@ -47,7 +47,7 @@ export const httpListener = ({
const marbleReq = req as HttpRequest;
const marbleRes = res as HttpResponse;
- marbleRes.send = handleResponse(marbleRes)(marbleReq);
+ marbleRes.send = handleResponse(ask)(marbleRes)(marbleReq);
marbleReq.response = marbleRes;
const routeSubject = resolve(marbleReq);

MODIFY
packages/core/src/response/response.handler.ts
packages/core/src/response/response.handler.ts
import * as url from 'url';
import { EMPTY } from 'rxjs';
+import { fold } from 'fp-ts/lib/Option';
+import { pipe } from 'fp-ts/lib/pipeable';
import { HttpEffectResponse } from '../effects/http-effects.interface';
import { HttpRequest, HttpResponse, HttpStatus } from '../http.interface';
import { bodyFactory } from './responseBody.factory';
import { headersFactory } from './responseHeaders.factory';
import { isStream } from '../+internal/utils';
+import { ContextProvider } from '../context/context.factory';
+import { ServerRequestMetadataStorageToken } from '../server/server.tokens';
+import { getTestingRequestIdHeader, isTestingMetadataOn } from '../+internal/testing';
-export const handleResponse = (res: HttpResponse) => (req: HttpRequest) => (effectResponse: HttpEffectResponse) => {
+export const handleResponse = (ask: ContextProvider) => (res: HttpResponse) => (req: HttpRequest) => (effectResponse: HttpEffectResponse) => {
if (res.finished) { return EMPTY; }
const status = effectResponse.status || HttpStatus.OK;
@@ -18,6 +23,15 @@ export const handleResponse = (res: HttpResponse) => (req: HttpRequest) => (effe
const bodyFactoryWithHeaders = bodyFactory(headers);
const body = bodyFactoryWithHeaders(effectResponse.body);
+ const testingHeader = getTestingRequestIdHeader(req);
+
+ if (isTestingMetadataOn()) {
+ pipe(
+ ask(ServerRequestMetadataStorageToken),
+ fold(() => undefined, storage => storage.set(testingHeader, req.meta)),
+ );
+ }
+
if (isStream(body)) {
res.writeHead(status, headers);
body.pipe(res);

MODIFY
packages/core/src/response/specs/response.handler.spec.ts
packages/core/src/response/specs/response.handler.spec.ts
import * as path from 'path';
import * as fs from 'fs';
-import { HttpRequest, HttpResponse, HttpStatus } from '../../http.interface';
+import { HttpResponse, HttpStatus } from '../../http.interface';
import { handleResponse } from '../response.handler';
import { DEFAULT_HEADERS } from '../responseHeaders.factory';
-import { ContentType } from '../../+internal';
-
-const createMockResponse = () => ({
- writeHead: jest.fn(),
- setHeader: jest.fn(),
- end: jest.fn(),
-} as any as HttpResponse);
-
-const createMockRequest = () => ({
- url: '/',
-} as any as HttpRequest);
+import { ContentType, createMockEffectContext, createHttpResponse, createHttpRequest } from '../../+internal';
describe('Response handler', () => {
+ const effectContext = createMockEffectContext();
let request;
let response;
let handle;
beforeEach(() => {
- request = createMockRequest();
- response = createMockResponse();
- handle = handleResponse(response)(request);
+ request = createHttpRequest({ url: '/', method: 'GET' });
+ response = createHttpResponse();
+ handle = handleResponse(effectContext.ask)(response)(request);
});
- it('sets provided status', () => {
+ test('sets provided status', () => {
handle({ status: HttpStatus.FORBIDDEN });
expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.FORBIDDEN, DEFAULT_HEADERS);
});
- it('sets status OK if is not explicitly set', () => {
+ test('sets status OK if is not explicitly set', () => {
handle({});
expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.OK, DEFAULT_HEADERS);
});
- it('sets default headers if are not explicitly set', () => {
+ test('sets default headers if are not explicitly set', () => {
handle({});
expect(response.writeHead).toHaveBeenCalledWith(HttpStatus.OK, DEFAULT_HEADERS);
});
- it('sets provided headers', () => {
+ 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);
});
- it('sets unefined response if body is not provided', () => {
+ test('sets unefined response if body is not provided', () => {
handle({});
expect(response.end).toHaveBeenCalledWith(undefined);
});
- it('sets stringified response if object body is provided', () => {
+ test('sets stringified response if object body is provided', () => {
handle({ body: { foo: 'bar' } });
expect(response.end).toHaveBeenCalledWith(JSON.stringify({ foo: 'bar' }));
});
- it('pipes body stream to response', () => {
+ test('pipes body stream to response', () => {
// given
const pathToFile = path.resolve(__dirname, '../../../../../assets/media', 'audio.mp3');
const body = fs.createReadStream(pathToFile);
@@ -73,10 +64,10 @@ describe('Response handler', () => {
expect(body.pipe).toHaveBeenCalledWith(response);
});
- it('returns immediately if response was finished', done => {
+ test('returns immediately if response was finished', done => {
// given
- response = { ...createMockResponse(), finished: true } as HttpResponse;
- handle = handleResponse(response)(response);
+ response = { ...createHttpResponse(), finished: true } as HttpResponse;
+ handle = handleResponse(effectContext.ask)(response)(response);
// when
const result = handle({});
@@ -84,14 +75,8 @@ describe('Response handler', () => {
// then
expect(response.end).not.toHaveBeenCalled();
result.subscribe(
- () => {
- fail('Stream should be empty');
- done();
- },
- () => {
- fail('Stream should be empty');
- done();
- },
+ () => fail('Stream should be empty'),
+ () => fail('Stream should be empty'),
() => {
expect(true).toBe(true);
done();

MODIFY
packages/core/src/server/server.factory.ts
packages/core/src/server/server.factory.ts
@@ -4,7 +4,9 @@ import { subscribeServerEvents } from './server.event.subscriber';
import { createContext, lookup, registerAll, bindTo } from '../context/context.factory';
import { createEffectContext } from '../effects/effectsContext.factory';
import { CreateServerConfig, Server } from './server.interface';
-import { ServerClientToken, ServerEventStreamToken } from './server.tokens';
+import { ServerClientToken, ServerEventStreamToken, ServerRequestMetadataStorageToken } from './server.tokens';
+import { insertIf, isTestingMetadataOn } from '../+internal';
+import { serverRequestMetadataStorage } from './server.metadata.storage';
const DEFAULT_HOSTNAME = '127.0.0.1';
@@ -16,10 +18,12 @@ export const createServer = (config: CreateServerConfig): Server => {
const boundServerEvent$ = bindTo(ServerEventStreamToken)(() => serverEvent$);
const boundServer = bindTo(ServerClientToken)(() => server);
+ const boundServerRequestMetadataStorage = bindTo(ServerRequestMetadataStorageToken)(serverRequestMetadataStorage);
const context = registerAll([
boundServer,
boundServerEvent$,
+ ...insertIf(isTestingMetadataOn(), boundServerRequestMetadataStorage),
...dependencies,
])(createContext());

ADD
packages/core/src/server/server.metadata.storage.ts
+import * as R from 'fp-ts/lib/Reader';
+import { pipe } from 'fp-ts/lib/pipeable';
+import { reader } from '../context/context.factory';
+import { HttpRequestMetadata } from '../http.interface';
+
+export type ServerRequestMetadataStorage = ReturnType<typeof serverRequestMetadataStorage>;
+
+export const serverRequestMetadataStorage = pipe(reader, R.map(() => {
+ const storage = new Map<string, HttpRequestMetadata>();
+
+ const set = (key: string | undefined, metadata: HttpRequestMetadata | undefined) => {
+ if (key && metadata) {
+ storage.set(key, metadata);
+ }
+ }
+
+ const get = (key: string) => {
+ const metadata = storage.get(key);
+ storage.delete(key);
+ return metadata;
+ }
+
+ return {
+ set,
+ get,
+ }
+}));

MODIFY
packages/core/src/server/server.tokens.ts
packages/core/src/server/server.tokens.ts
@@ -3,7 +3,9 @@ import { AllServerEvents } from './server.event';
import { createContextToken } from '../context/context.token.factory';
import { HttpServer } from '../http.interface';
import { Routing } from '../router/router.helpers';
+import { ServerRequestMetadataStorage } from './server.metadata.storage';
export const ServerEventStreamToken = createContextToken<Observable<AllServerEvents>>('ServerEventStreamToken');
export const ServerClientToken = createContextToken<HttpServer>('ServerClientToken');
export const ServerRoutingToken = createContextToken<Routing>('ServerRoutingToken');
+export const ServerRequestMetadataStorageToken = createContextToken<ServerRequestMetadataStorage>('ServerRequestMetadataStorageToken');

feat(core): request metadata storage