apiveritas
Version:
Lightweight CLI tool for consumer-driven API contract testing via JSON schema and payload comparisons.
169 lines (146 loc) • 5.89 kB
text/typescript
/**
* @file MockServer.ts
* @author Mario Galea
* @description
* The MockServer class provides an Express-based HTTP server that serves static
* JSON mock responses for API contract testing. It maps incoming HTTP requests
* to JSON files in a configured directory based on the request method and path,
* then returns the file contents as the HTTP response. The server supports
* basic authentication and can be started and stopped programmatically, making
* it suitable for development and CI environments where a mock backend is needed.
*/
import express, { RequestHandler } from 'express';
import basicAuth from 'express-basic-auth';
import fs from 'fs';
import path from 'path';
import bodyParser from 'body-parser';
import http from 'http';
import { Logger } from '../utils/Logger';
/**
* MockServer serves static mock JSON responses based on HTTP method and URL path.
* It is typically used in CI or development environments for API contract testing.
*/
export class MockServer {
private app = express();
private readonly port = 3000;
private readonly mockDir = path.resolve('apiveritas/tests/mock/mock-responses');
private logger = new Logger({ level: 'info' });
private server?: http.Server;
/**
* Constructs a new MockServer instance.
* Sets up middleware for JSON parsing and basic authentication,
* checks the mock responses directory existence,
* and configures routing for serving mock responses.
*/
constructor() {
this.logger.info('Initializing MockServer...');
this.logger.info(`Expected mock response directory: ${this.mockDir}`);
const mockDirExists = fs.existsSync(this.mockDir);
if (!mockDirExists) {
this.logger.warn(`Mock directory does not exist: ${this.mockDir}`);
} else {
this.logger.info('Mock directory found.');
}
this.app.use(bodyParser.json());
this.app.use(basicAuth({
users: { 'admin': 'secret' },
unauthorizedResponse: () => 'Unauthorized',
}));
this.logger.info('Basic authentication configured for user: "admin"');
this.setupRoutes();
this.logger.info('Finished setting up routes and middleware.');
}
/**
* Sets up a universal route handler for all incoming HTTP requests.
* The handler attempts to locate a corresponding mock JSON response file
* based on HTTP method and path, then returns its contents as the response.
* If no matching file is found or the file contains invalid JSON,
* appropriate HTTP error codes and messages are returned.
*
* @private
* @returns {void}
*/
private setupRoutes(): void {
const handler: RequestHandler = (req, res) => {
const { method, path: reqPath } = req;
this.logger.info(`Incoming request: ${method} ${reqPath}`);
const match = this.findMatchingTest(method, reqPath);
if (!match) {
this.logger.warn(`No mock match found for: ${method} ${reqPath}`);
res.status(404).send(`No mock response found for ${method} ${reqPath}`);
return;
}
this.logger.info(`Matched to file: ${match}.json`);
const mockFile = path.join(this.mockDir, `${match}.json`);
if (!fs.existsSync(mockFile)) {
this.logger.error(`Mock file not found: ${mockFile}`);
res.status(500).send(`Mock file not found: ${match}.json`);
return;
}
const content = fs.readFileSync(mockFile, 'utf-8');
try {
const json = JSON.parse(content);
res.json(json);
} catch (e) {
this.logger.error(`Invalid JSON in mock file: ${match}.json`);
res.status(500).send('Invalid mock JSON file');
}
};
this.app.all(/.*/, handler);
}
/**
* Matches an incoming HTTP request method and path to a mock JSON file name.
* Converts the request path to a safe filename by stripping leading/trailing slashes
* and replacing internal slashes with underscores.
*
* Example: GET /api/user -> GET_api_user.json
*
* @private
* @param {string} method - The HTTP method (e.g., GET, POST).
* @param {string} reqPath - The request URL path.
* @returns {string | null} The matching filename without extension, or null if no file exists.
*/
private findMatchingTest(method: string, reqPath: string): string | null {
const cleanPath = reqPath.replace(/^\/|\/$/g, '').replace(/\//g, '_');
const fileName = `${method.toUpperCase()}_${cleanPath}`;
const mockFilePath = path.join(this.mockDir, `${fileName}.json`);
return fs.existsSync(mockFilePath) ? fileName : null;
}
/**
* Starts the mock server and begins listening on the configured port.
*
* @public
* @returns {Promise<void>} A promise that resolves once the server is successfully started.
*/
public start(): Promise<void> {
return new Promise((resolve) => {
this.server = this.app.listen(this.port, () => {
this.logger.info(`Mock server is running at http://localhost:${this.port}`);
resolve();
});
});
}
/**
* Stops the mock server if it is currently running.
*
* @public
* @returns {Promise<void>} A promise that resolves once the server is stopped.
* If the server is not running, the promise resolves immediately.
* If an error occurs while closing the server, the promise rejects with the error.
*/
public stop(): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.server) {
return resolve();
}
this.server.close((err) => {
if (err) {
this.logger.error('Error while stopping mock server.');
return reject(err);
}
this.logger.info('Mock server stopped.');
resolve();
});
});
}
}