diff --git a/.gitignore b/.gitignore index b51ea71..d088a01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ -build/ \ No newline at end of file +build/ +.DS_Store +*.log \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a610bf3..dfa9326 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@requestly/mock-server", - "version": "0.1.4", + "version": "0.1.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@requestly/mock-server", - "version": "0.1.4", + "version": "0.1.6", "license": "ISC", "dependencies": { + "@types/har-format": "^1.2.14", "cors": "^2.8.5", "express": "^4.18.2", "path-to-regexp": "^0.1.7" @@ -136,6 +137,11 @@ "@types/range-parser": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.14.tgz", + "integrity": "sha512-pEmBAoccWvO6XbSI8A7KvIDGEoKtlLWtdqVCKoVBcCDSFvR4Ijd7zGLu7MWGEqk2r8D54uWlMRt+VZuSrfFMzQ==" + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -1476,6 +1482,11 @@ "@types/range-parser": "*" } }, + "@types/har-format": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.14.tgz", + "integrity": "sha512-pEmBAoccWvO6XbSI8A7KvIDGEoKtlLWtdqVCKoVBcCDSFvR4Ijd7zGLu7MWGEqk2r8D54uWlMRt+VZuSrfFMzQ==" + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", diff --git a/package.json b/package.json index 2673b2a..9ffd0bb 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typescript": "^4.9.4" }, "dependencies": { + "@types/har-format": "^1.2.14", "cors": "^2.8.5", "express": "^4.18.2", "path-to-regexp": "^0.1.7" diff --git a/src/core/common/mockHandler.ts b/src/core/common/mockHandler.ts index 4130222..4ac88d1 100644 --- a/src/core/common/mockHandler.ts +++ b/src/core/common/mockHandler.ts @@ -30,7 +30,10 @@ class MockServerHandler { password: queryParams[RQ_PASSWORD] as string } ); - return mockResponse; + return { + ...mockResponse, + metadata: { mockId: mockData.id }, + } } console.debug("[Debug] No Mock Selected"); diff --git a/src/core/server.ts b/src/core/server.ts index ee825fa..3ea46c1 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -5,6 +5,8 @@ import MockServerHandler from "./common/mockHandler"; import IConfigFetcher from "../interfaces/configFetcherInterface"; import storageService from "../services/storageService"; import { MockServerResponse } from "../types"; +import ILogSink from "../interfaces/logSinkInterface"; +import { HarMiddleware } from "../middlewares/har"; interface MockServerConfig { port: number; @@ -14,14 +16,16 @@ interface MockServerConfig { class MockServer { config: MockServerConfig; configFetcher: IConfigFetcher; + logSink: ILogSink; app: Express - constructor (port: number = 3000, configFetcher: IConfigFetcher, pathPrefix: string = "") { + constructor (port: number = 3000, configFetcher: IConfigFetcher, logSink: ILogSink, pathPrefix: string = "") { this.config = { port, pathPrefix }; this.configFetcher = configFetcher; + this.logSink = logSink; this.app = this.setup(); } @@ -36,6 +40,12 @@ class MockServer { this.initStorageService(); const app = express(); + + // Use middleware to parse `application/json` and `application/x-www-form-urlencoded` body data + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + app.use(HarMiddleware); app.use((_, res, next) => { res.set({ @@ -73,7 +83,9 @@ class MockServer { const mockResponse: MockServerResponse = await MockServerHandler.handleEndpoint(req); console.debug("[Debug] Final Mock Response", mockResponse); - return res.status(mockResponse.statusCode).set(mockResponse.headers).end(mockResponse.body); + + res.locals.metadata = mockResponse.metadata; + return res.status(mockResponse.statusCode).set(mockResponse.headers).send(mockResponse.body); }); return app; @@ -81,6 +93,7 @@ class MockServer { initStorageService = () => { storageService.setConfigFetcher(this.configFetcher); + storageService.setLogSink(this.logSink); } } diff --git a/src/core/utils/harFormatter.ts b/src/core/utils/harFormatter.ts new file mode 100644 index 0000000..d299fd4 --- /dev/null +++ b/src/core/utils/harFormatter.ts @@ -0,0 +1,104 @@ +import type { + Request as HarRequest, + Response as HarResponse, + Header as HarHeader, +} from "har-format"; +import { IncomingHttpHeaders, OutgoingHttpHeaders } from "http"; +import { Request, Response } from "express"; +import { RequestMethod } from "../../types"; + +export const getHarHeaders = (headers: IncomingHttpHeaders | OutgoingHttpHeaders): HarHeader[] => { + const harHeaders: HarHeader[] = []; + + for (const headerName in headers) { + const headerValue = headers[headerName]; + // Header values can be string | string[] according to Node.js typings, + // but HAR format requires a string, so we need to handle this. + if (headerValue) { + const value = Array.isArray(headerValue) ? headerValue.join('; ') : headerValue; + harHeaders.push({ name: headerName, value: value.toString() }); + } + } + + return harHeaders; +}; + +export const getPostData = (req: Request): HarRequest['postData'] => { + if ([RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH].includes(req.method as RequestMethod)) { + const postData: any = { + mimeType: req.get('Content-Type') || 'application/json', + text: '', + params: [], + }; + + // When the body is URL-encoded, the body should be converted into params + if (postData.mimeType === 'application/x-www-form-urlencoded' && typeof req.body === 'object') { + postData.params = Object.keys(req.body).map(key => ({ + name: key, + value: req.body[key], + })); + } else if (req.body) { + try { + postData.text = typeof req.body === 'string' ? req.body : JSON.stringify(req.body); + } catch (error) { + postData.text = ""; + } + } + + return postData; + } + return undefined; +} + +export const getHarRequestQueryString = (req: Request): HarRequest['queryString'] => { + // req.query is any, which isn't ideal; we need to ensure it's an object with string values + const queryObject: Request['query'] = req.query; + + // Convert the object into an array of name-value pairs + const queryString: HarRequest['queryString'] = []; + + for (const [name, value] of Object.entries(queryObject)) { + if (Array.isArray(value)) { + // If the value is an array, add an entry for each value + value.forEach(val => queryString.push({ name, value: val as string })); + } else { + // Otherwise, just add the name-value pair directly + queryString.push({ name, value: value as string }); + } + } + + return queryString; +} + +export const buildHarRequest = (req: Request): HarRequest => { + return { + method: req.method, + url: req.url, + httpVersion: req.httpVersion, + cookies: [], + headers: getHarHeaders(req.headers), + queryString: getHarRequestQueryString(req), + postData: getPostData(req), + headersSize: -1, + bodySize: -1, + } +}; + +export const buildHarResponse = (res: Response, metadata?: any): HarResponse => { + const { body } = metadata; + return { + status: res.statusCode, + statusText: res.statusMessage, + httpVersion: res.req.httpVersion, + cookies: [], + headers: getHarHeaders(res.getHeaders()), + content: { + size: Buffer.byteLength(JSON.stringify(body)), + mimeType: res.get('Content-Type') || 'application/json', + text: JSON.stringify(body), + }, + redirectURL: '', + headersSize: -1, + bodySize: -1, + } +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 31400d5..656d50f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ import IConfigFetcher from "./interfaces/configFetcherInterface"; +import IlogSink from "./interfaces/logSinkInterface"; import MockServer from "./core/server"; import { Mock as MockSchema, MockMetadata as MockMetadataSchema, Response as MockResponseSchema } from "./types/mock"; export { MockServer, IConfigFetcher, + IlogSink, MockSchema, MockMetadataSchema, MockResponseSchema, diff --git a/src/interfaces/logSinkInterface.ts b/src/interfaces/logSinkInterface.ts new file mode 100644 index 0000000..cc410f9 --- /dev/null +++ b/src/interfaces/logSinkInterface.ts @@ -0,0 +1,9 @@ +import { Log } from "../types"; + +class ILogSink { + store = async (log: Log): Promise => { + return; + } +} + +export default ILogSink; \ No newline at end of file diff --git a/src/middlewares/har.ts b/src/middlewares/har.ts new file mode 100644 index 0000000..acab6f8 --- /dev/null +++ b/src/middlewares/har.ts @@ -0,0 +1,32 @@ +import type { Entry } from "har-format"; +import { NextFunction, Request, Response } from "express"; +import storageService from "../services/storageService"; +import { buildHarRequest, buildHarResponse } from "../core/utils/harFormatter"; + + +export const HarMiddleware = (req: Request, res: Response, next: NextFunction) => { + const originalSend = res.send; + + const requestStartTime = new Date(); + const requestStartTimeStamp: string = requestStartTime.toISOString(); + + let responseBody: string; + + res.send = function (body) { + responseBody = body; + return originalSend.call(this, body); + }; + + res.once('finish', () => { + const HarEntry: Partial = { + time: Date.now() - requestStartTime.getTime(), + startedDateTime: requestStartTimeStamp, + request: buildHarRequest(req), + response: buildHarResponse(res, { body: responseBody }), + } + + storageService.storeLog({ mockId: res.locals.metadata.mockId, HarEntry, }) + }); + + next(); +}; \ No newline at end of file diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 9625eb7..457460e 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -1,10 +1,14 @@ import IConfigFetcher from "../interfaces/configFetcherInterface"; +import ILogSink from "../interfaces/logSinkInterface"; +import { Log } from "../types"; class StorageService { configFetcher ?: IConfigFetcher|null = null; + logSink ?: ILogSink|null = null; - constructor(configFetcher ?: IConfigFetcher ) { + constructor(configFetcher ?: IConfigFetcher, logSink ?: ILogSink) { this.configFetcher = configFetcher; + this.logSink = logSink; } // TODO: This should be set when starting the mock server @@ -12,6 +16,10 @@ class StorageService { this.configFetcher = configFetcher; } + setLogSink(logSink: ILogSink) { + this.logSink = logSink; + } + getMockSelectorMap = async (kwargs ?: any): Promise => { return this.configFetcher?.getMockSelectorMap(kwargs); }; @@ -19,6 +27,10 @@ class StorageService { getMock = async (id: string, kwargs?: any): Promise => { return this.configFetcher?.getMock(id, kwargs); } + + storeLog = async (log: Log): Promise => { + await this.logSink?.store(log); + } } const storageService = new StorageService(); diff --git a/src/test/FileLogSink.ts b/src/test/FileLogSink.ts new file mode 100644 index 0000000..ccb5bc0 --- /dev/null +++ b/src/test/FileLogSink.ts @@ -0,0 +1,21 @@ +import fs from 'fs'; + +import ILogSink from "../interfaces/logSinkInterface"; +import { Log } from "../types"; + + +class FileLogSink implements ILogSink { + store = async (log: Log): Promise => { + const logLine = `${JSON.stringify(log.HarEntry)}\n`; + fs.writeFile(`${log.mockId}.log`, logLine, { flag: 'a+' }, (err) => { + if(err) { + console.log("Error dumping log to file."); + throw err; + } + }); + Promise.resolve(); + } +} + +const fileLogSink = new FileLogSink(); +export default fileLogSink; diff --git a/src/test/index.ts b/src/test/index.ts index 48d4c3f..6a4c98f 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1,6 +1,7 @@ import MockServer from "../core/server"; import firebaseConfigFetcher from "./firebaseConfigFetcher"; +import fileLogSink from "./FileLogSink"; -const server = new MockServer(3000, firebaseConfigFetcher, "/mocksv2"); +const server = new MockServer(3000, firebaseConfigFetcher, fileLogSink, "/mocksv2"); console.log(server.app); server.start(); diff --git a/src/types/index.ts b/src/types/index.ts index a0d3c45..563025e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +import type { Entry } from "har-format"; import { HttpStatusCode } from "../enums/mockServerResponse"; export enum RequestMethod { @@ -18,4 +19,10 @@ export interface MockServerResponse { body: string, statusCode: HttpStatusCode, headers: { [key: string]: string } + metadata?: { mockId: string } } + +export interface Log { + mockId: string; + HarEntry: Partial; +} \ No newline at end of file