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/README.md b/README.md index 78e9655..654b089 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ npm i @requestly/mock-server ``` javascript import * as functions from 'firebase-functions'; import { MockServer } from '@requestly/mock-server'; -import firebaseConfigFetcher from '../firebaseConfigFetcher'; +import firebaseConfig from '../firebaseConfig'; const startMockServer = () => { - const expressApp = new MockServer(3000, firebaseConfigFetcher, '/api/mockv2').app; + const expressApp = new MockServer(3000, firebaseConfig, '/api/mockv2').app; return functions.runWith({ minInstances: isProdEnv() ? 1 : 0 }).https.onRequest(expressApp); }; @@ -36,7 +36,7 @@ export const handleMockRequest = startMockServer(); ``` ``` javascript -class FirebaseConfigFetcher implements IConfigFetcher { +class FirebaseConfig implements IConfig { getMockSelectorMap = (kwargs?: any) => { /** * Fetch and return mockSelectorMap from firestore @@ -54,10 +54,16 @@ class FirebaseConfigFetcher implements IConfigFetcher { * Fetch mock details from firestore */ } + + storeLog? = (log: Log) => { + /** + * Store log in cloud storages + */ + } } -const firebaseConfigFetcher = new FirebaseConfigFetcher(); -export default firebaseConfigFetcher; +const firebaseConfig = new FirebaseConfig(); +export default firebaseConfig; ``` @@ -69,9 +75,9 @@ export default firebaseConfigFetcher; 1. Request coming from GET `https://username.requestly.dev/users` 2. Firebase Function passes the request to @requestly/mock-server 3. @requestly/mock-server - MockSelector - a. Fetches all the available mocks using `IConfigFetcher.getMockSelectorMap()` (Firestore in case of Requestly) + a. Fetches all the available mocks using `IConfig.getMockSelectorMap()` (Firestore in case of Requestly) b. Select mock if any endpoint+method matches the incoming request (GET /users) - c. Fetch Mock using `IConfigFetcher.getMock(mockId)` and pass it to MockProcessor + c. Fetch Mock using `IConfig.getMock(mockId)` and pass it to MockProcessor 4. @requestly/mock-server - MockProcessor a. Process Mock - Response Rendering b. Return Response diff --git a/package-lock.json b/package-lock.json index aeb3932..d0fdffc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.13", "license": "ISC", "dependencies": { + "@types/har-format": "^1.2.14", "cors": "^2.8.5", "express": "^4.18.2", "handlebars": "^4.7.8", @@ -137,6 +138,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", @@ -1666,6 +1672,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 296f9f5..1be3b3c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "typescript": "^4.9.4" }, "dependencies": { + "@types/har-format": "^1.2.14", "cors": "^2.8.5", "express": "^4.18.2", "handlebars": "^4.7.8", diff --git a/src/core/common/mockHandler.ts b/src/core/common/mockHandler.ts index a349148..530ad4b 100644 --- a/src/core/common/mockHandler.ts +++ b/src/core/common/mockHandler.ts @@ -25,7 +25,10 @@ class MockServerHandler { req, 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 de6d2e8..a8cceb8 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -2,34 +2,39 @@ import express, { Request, Response, Express } from "express"; import cors from "cors"; import MockServerHandler from "./common/mockHandler"; -import IConfigFetcher from "../interfaces/configFetcherInterface"; +import { Config } from "../interfaces/config"; import storageService from "../services/storageService"; import { MockServerResponse } from "../types"; +import { HarMiddleware } from "../middlewares/har"; import { cleanupPath } from "./utils"; -interface MockServerConfig { +interface MockServerOptions { port: number; pathPrefix: string; + storageConfig: Config; } +/* To make the constructor options optional except for storageConfig */ +type MockServerConstructorOptions = Pick & Partial; + class MockServer { - config: MockServerConfig; - configFetcher: IConfigFetcher; + serverOptions: MockServerOptions; app: Express - constructor (port: number = 3000, configFetcher: IConfigFetcher, pathPrefix: string = "") { - this.config = { - port, - pathPrefix + constructor (options: MockServerConstructorOptions) { + this.serverOptions = { + storageConfig: options.storageConfig, + port: options.port ?? 3000, + pathPrefix: options.pathPrefix ?? "", }; - this.configFetcher = configFetcher; + this.app = this.setup(); } start = () => { - this.app.listen(this.config.port, () => { - console.log(`Mock Server Listening on port ${this.config.port}`); + this.app.listen(this.serverOptions.port, () => { + console.log(`Mock Server Listening on port ${this.serverOptions.port}`); }) } @@ -37,6 +42,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({ @@ -56,32 +67,34 @@ class MockServer { })); // pathPrefix to handle /mockv2 prefix in cloud functions - const regex = new RegExp(`${this.config.pathPrefix}\/(.+)`); + const regex = new RegExp(`${this.serverOptions.pathPrefix}\/(.+)`); app.all(regex, async (req: Request, res: Response) => { console.log(`Initial Request`); console.log(`Path: ${req.path}`); console.log(`Query Params: ${JSON.stringify(req.query)}`); // Stripping URL prefix - if(req.path.indexOf(this.config.pathPrefix) === 0) { - console.log(`Stripping pathPrefix: ${this.config.pathPrefix}`); + if(req.path.indexOf(this.serverOptions.pathPrefix) === 0) { + console.log(`Stripping pathPrefix: ${this.serverOptions.pathPrefix}`); Object.defineProperty(req, 'path', { - value: cleanupPath(req.path.slice(this.config.pathPrefix.length)), + value: cleanupPath(req.path.slice(this.serverOptions.pathPrefix.length)), writable: true }); console.log(`Path after stripping prefix and cleanup: ${req.path}`); } 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); + console.debug("[Debug] Final Mock Response", mockResponse); + + res.locals.rq_metadata = mockResponse.metadata; + return res.status(mockResponse.statusCode).set(mockResponse.headers).send(mockResponse.body); }); return app; } initStorageService = () => { - storageService.setConfigFetcher(this.configFetcher); + storageService.setConfig(this.serverOptions.storageConfig); } } diff --git a/src/core/utils/harFormatter.ts b/src/core/utils/harFormatter.ts new file mode 100644 index 0000000..82ee6a0 --- /dev/null +++ b/src/core/utils/harFormatter.ts @@ -0,0 +1,102 @@ +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'] => { + const queryObject: Request['query'] = req.query; + + const queryString: HarRequest['queryString'] = []; + + for (const [name, value] of Object.entries(queryObject)) { + if (Array.isArray(value)) { + value.forEach(val => queryString.push({ name, value: val as string })); + } else { + queryString.push({ name, value: value as string }); + } + } + + return queryString; +} + +export const buildHarRequest = (req: Request): HarRequest => { + const requestData = getPostData(req) + return { + method: req.method, + url: req.url, + httpVersion: req.httpVersion, + cookies: [], + headers: getHarHeaders(req.headers), + queryString: getHarRequestQueryString(req), + postData: requestData, + headersSize: -1, // not calculating for now + bodySize: requestData ? Buffer.byteLength(requestData.text!) : -1, + } +}; + +export const buildHarResponse = (res: Response, metadata?: any): HarResponse => { + const { body } = metadata; + const bodySize = body ? Buffer.byteLength(JSON.stringify(body || {})) : -1; + return { + status: res.statusCode, + statusText: res.statusMessage, + httpVersion: res.req.httpVersion, + cookies: [], + headers: getHarHeaders(res.getHeaders()), + content: { + size: bodySize, // same as bodySize since serving uncompressed + mimeType: res.get('Content-Type') || 'application/json', + text: JSON.stringify(body), + }, + redirectURL: '', // todo: implement when we integrate rules to mocks + headersSize: -1, // not calculating for now + bodySize, + } +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 31400d5..4cb107e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,12 @@ -import IConfigFetcher from "./interfaces/configFetcherInterface"; +import {Config, ISink, ISource} from "./interfaces/config"; import MockServer from "./core/server"; import { Mock as MockSchema, MockMetadata as MockMetadataSchema, Response as MockResponseSchema } from "./types/mock"; - +import {Log as MockLog} from "./types"; export { MockServer, - IConfigFetcher, + Config, ISink, ISource, MockSchema, MockMetadataSchema, MockResponseSchema, + MockLog, }; diff --git a/src/interfaces/configFetcherInterface.ts b/src/interfaces/config.ts similarity index 52% rename from src/interfaces/configFetcherInterface.ts rename to src/interfaces/config.ts index c6aa046..e4953b0 100644 --- a/src/interfaces/configFetcherInterface.ts +++ b/src/interfaces/config.ts @@ -1,16 +1,27 @@ -import { Mock } from "../types/mock"; -class IConfigFetcher { +import { Log } from "../types"; + +export class ISink { + /** + * specify how and where to store logs from mock execution + */ + sendLog = (log: Log): Promise => { + return Promise.resolve(); + } +} + + +import { Mock } from "../types/mock" + +export class ISource { /** * * @param id Mock Id * @param kwargs Contains extra val required for storage fetching. Eg. uid in case of firebaseStorageService * @returns Return the Mock definition */ - getMock = (id: string, kwargs?: any): Mock | null => { - return null - } + getMock = (id: string, kwargs?: any): Mock | null => {return null} /** @@ -25,9 +36,11 @@ class IConfigFetcher { * } * } */ - getMockSelectorMap = (kwargs?: any): any => { - return {} - } + getMockSelectorMap = (kwargs?: any): any => {return {}} } -export default IConfigFetcher; \ No newline at end of file + +export interface Config { + src: ISource; + sink?: ISink; +} \ No newline at end of file diff --git a/src/middlewares/har.ts b/src/middlewares/har.ts new file mode 100644 index 0000000..e4dd002 --- /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.rq_metadata.mockId, HarEntry, }) + }); + + next(); +}; \ No newline at end of file diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 9625eb7..0568028 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -1,23 +1,31 @@ -import IConfigFetcher from "../interfaces/configFetcherInterface"; +import {Config, ISink, ISource} from "../interfaces/config"; +import { Log } from "../types"; class StorageService { - configFetcher ?: IConfigFetcher|null = null; + source: ISource | null = null; + sink: ISink | null = null; - constructor(configFetcher ?: IConfigFetcher ) { - this.configFetcher = configFetcher; + constructor(config?: Config) { + this.source = config?.src || null; + this.sink = config?.sink || null; } // TODO: This should be set when starting the mock server - setConfigFetcher = (configFetcher: IConfigFetcher) => { - this.configFetcher = configFetcher; + setConfig = (config: Config) => { + this.source = config.src || null; + this.sink = config.sink || null; } getMockSelectorMap = async (kwargs ?: any): Promise => { - return this.configFetcher?.getMockSelectorMap(kwargs); + return this.source?.getMockSelectorMap(kwargs); }; getMock = async (id: string, kwargs?: any): Promise => { - return this.configFetcher?.getMock(id, kwargs); + return this.source?.getMock(id, kwargs); + } + + storeLog = async (log: Log): Promise => { + await this.sink?.sendLog(log); } } diff --git a/src/test/dummy/FileLogSink.ts b/src/test/dummy/FileLogSink.ts new file mode 100644 index 0000000..d3cde9b --- /dev/null +++ b/src/test/dummy/FileLogSink.ts @@ -0,0 +1,17 @@ +import fs from 'fs'; + +import { ISink } from "../../interfaces/config"; +import { Log } from "../../types"; + + +export class FileLogSink implements ISink { + sendLog = 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; + } + }); + } +} diff --git a/src/test/firebaseConfigFetcher.ts b/src/test/dummy/dummySource.ts similarity index 63% rename from src/test/firebaseConfigFetcher.ts rename to src/test/dummy/dummySource.ts index ff4a125..d9d3712 100644 --- a/src/test/firebaseConfigFetcher.ts +++ b/src/test/dummy/dummySource.ts @@ -1,9 +1,7 @@ -import { dummyMock1, dummyMock2, dummyMock3, dummyMock4, getSelectorMap } from "./dummy/mock1"; -import IConfigFetcher from "../interfaces/configFetcherInterface"; +import { dummyMock1, dummyMock2, dummyMock3, dummyMock4, getSelectorMap } from "./mock1"; +import { ISource } from "../../interfaces/config"; - -// TODO: Fetch from Firestore and return -class FirebaseConfigFetcher implements IConfigFetcher { +export class DummySource implements ISource { getMockSelectorMap = (kwargs?: any) => { return getSelectorMap(); }; @@ -24,7 +22,4 @@ class FirebaseConfigFetcher implements IConfigFetcher { return null; } -} - -const firebaseConfigFetcher = new FirebaseConfigFetcher(); -export default firebaseConfigFetcher; +} \ No newline at end of file diff --git a/src/test/index.ts b/src/test/index.ts index 4ba39e5..0651f7c 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1,6 +1,10 @@ import MockServer from "../core/server"; -import firebaseConfigFetcher from "./firebaseConfigFetcher"; +import TestConfig from "./testConfig"; -const server = new MockServer(3001, firebaseConfigFetcher, "/mocksv2"); -console.log(server.app); +const server = new MockServer({ + port: 3001, + pathPrefix: "/mocksv2", + storageConfig: TestConfig +}); +console.debug(server.app); server.start(); diff --git a/src/test/testConfig.ts b/src/test/testConfig.ts new file mode 100644 index 0000000..b2f3592 --- /dev/null +++ b/src/test/testConfig.ts @@ -0,0 +1,11 @@ + +import { Config } from "../interfaces/config"; +import { DummySource } from "./dummy/dummySource"; +import { FileLogSink } from "./dummy/FileLogSink"; + +const testConfig: Config = { + src: new DummySource(), + sink: new FileLogSink(), +} +export default testConfig; + 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