From 7b37d71cf2dd91f383beb52eeba435e1a9f23704 Mon Sep 17 00:00:00 2001 From: Many0nne Date: Wed, 25 Mar 2026 16:27:32 +0100 Subject: [PATCH] feat: add strict/dev mock modes specification --- .gitignore | 1 + CLAUDE.md | 13 +++-- README.md | 47 ++++++++++++++++ package-lock.json | 6 --- src/cli/wizard.ts | 16 +++++- src/index.ts | 19 ++++++- src/server.ts | 14 ++--- src/types/config.ts | 13 +++++ tests/integration/server.integration.test.ts | 56 ++++++++++++++++++++ 9 files changed, 167 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 3b07fa7..e61f67e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ temp/ # Config files .mock-config.json +docs/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3419c26..f1b4b3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,13 @@ TypeScript is strict (`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexe **Entry point**: `src/index.ts` — parses CLI args via Commander; if no args, runs the interactive `src/cli/wizard.ts`. Both paths call `startServer(config)`. +**Mock modes** (`mockMode: 'dev' | 'strict'`, default `'dev'`): +- `dev`: `statusOverride` and `latency` middlewares are mounted +- `strict`: those middlewares are not mounted at all — clean REST simulation +- Resolution order: CLI `--mock-mode` > `MOCK_API_MODE` env var > config file > default (`'dev'`) + **Request lifecycle** (`src/server.ts` → `src/core/router.ts`): -1. Express middleware chain: CORS → JSON → logger → `statusOverride` → optional latency +1. Express middleware chain: CORS → JSON → logger → `statusOverride` (dev only) → latency (dev only, if configured) 2. All non-system routes hit `dynamicRouteHandler` (catch-all `app.all('*')`) 3. Router calls `findTypeForUrl(url, typesDir)` to resolve a TypeScript interface name from the URL 4. Calls `generateMockFromInterface` or `generateMockArray` from `src/core/parser.ts` @@ -47,11 +52,11 @@ TypeScript is strict (`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexe - After generation, `extractConstraints` parses the TypeScript AST (via `typescript` compiler API) for JSDoc annotations (`@min`, `@max`, `@minLength`, `@maxLength`, `@pattern`, `@enum`) - `applyConstraintsToMock` in `src/core/constrainedGenerator.ts` then regenerates non-conforming fields using Faker -**Special headers**: -- `x-mock-status: ` — forces the response HTTP status code (handled by `src/middlewares/statusOverride.ts`) +**Special headers** (dev mode only): +- `x-mock-status: ` — forces the response HTTP status code (handled by `src/middlewares/statusOverride.ts`; ignored in `strict` mode by not mounting the middleware) **System routes** (not matched by dynamic handler): - `GET /health` — server status + cache stats - `GET /api-docs` — Swagger UI (spec auto-regenerated on hot-reload file changes) -**Key types** (`src/types/config.ts`): `ServerConfig`, `RouteTypeMapping`, `InterfaceMetadata`, `ParsedSchema`, `MockGenerationOptions` +**Key types** (`src/types/config.ts`): `ServerConfig`, `MockMode`, `RouteTypeMapping`, `InterfaceMetadata`, `ParsedSchema`, `MockGenerationOptions` diff --git a/README.md b/README.md index ff69989..4b5b207 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,21 @@ Options: -t, --types-dir Directory with TypeScript types (required) -p, --port Server port (default: 8080) -l, --latency Latency simulation "min-max" (e.g., 500-2000) + --mock-mode Mock mode (default: dev) --no-hot-reload Disable auto-reload on changes --no-cache Disable schema caching -v, --verbose Enable verbose logging -h, --help Show help ``` +**Environment Variables** + +| Variable | Values | Description | +|---|---|---| +| `MOCK_API_MODE` | `strict`, `dev` | Override mock mode without CLI flag | + +Resolution order: CLI `--mock-mode` > `MOCK_API_MODE` env var > config file > default (`dev`). + **Step 3: Call your API** The server enforces idiomatic REST URL patterns: @@ -297,6 +306,44 @@ export interface Product { --- +## Mock Modes + +The server supports two modes controlled by `mockMode`: + +| Mode | Description | +|---|---| +| `dev` (default) | All mock features enabled: `x-mock-status` header, artificial latency | +| `strict` | Clean REST simulation — mock features disabled, behaviour matches a real API | + +In `strict` mode, the `statusOverride` and `latency` middlewares are not mounted at all. + +```bash +# CLI +npx ts-mock-proxy --types-dir ./types --mock-mode strict + +# Environment variable +MOCK_API_MODE=strict npx ts-mock-proxy --types-dir ./types +``` + +### Mock features (dev mode only, non-prod) + +These features are active only in `dev` mode and should not be used to simulate real API behaviour: + +**`x-mock-status`** — forces any response to return the specified HTTP status code: + +```bash +# Force a 503 response +curl -H "x-mock-status: 503" http://localhost:8080/users +``` + +**Artificial latency** — simulates network delay (configured via `--latency` or the wizard): + +```bash +npx ts-mock-proxy --types-dir ./types --latency 200-800 +``` + +--- + ## How It Works The server enforces idiomatic REST URL patterns. URLs are parsed into segments (stripping `api` and `v{n}` prefixes) and each segment is classified as either a **collection name** or an **ID** (numeric, UUID, or MongoDB ObjectId). diff --git a/package-lock.json b/package-lock.json index 634ca62..3ed3089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1814,7 +1813,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2299,7 +2297,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3243,7 +3240,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4193,7 +4189,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6652,7 +6647,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/cli/wizard.ts b/src/cli/wizard.ts index 15af76d..8b9627e 100644 --- a/src/cli/wizard.ts +++ b/src/cli/wizard.ts @@ -2,7 +2,7 @@ import inquirer from 'inquirer'; import * as path from 'path'; import * as fs from 'fs'; import chalk from 'chalk'; -import { ServerConfig } from '../types/config'; +import { ServerConfig, MockMode } from '../types/config'; import { logger } from '../utils/logger'; import { loadSavedConfig, saveConfig } from '../utils/configPersistence'; @@ -122,9 +122,20 @@ export async function runWizard(): Promise { let latency: { min: number; max: number } | undefined = savedConfig?.latency; let verbose = savedConfig?.verbose ?? false; let writeMethods: ServerConfig['writeMethods'] = savedConfig?.writeMethods; + let mockMode: MockMode = savedConfig?.mockMode ?? 'dev'; if (advancedAnswer.showAdvanced) { const advOptions = await inquirer.prompt([ + { + type: 'list', + name: 'mockMode', + message: 'Mock mode:', + choices: [ + { name: 'dev — all mock features enabled (status override, artificial latency)', value: 'dev' }, + { name: 'strict — clean REST simulation, mock features disabled', value: 'strict' }, + ], + default: mockMode, + }, { type: 'confirm', name: 'hotReload', @@ -151,6 +162,7 @@ export async function runWizard(): Promise { }, ]); + mockMode = advOptions.mockMode; hotReload = advOptions.hotReload; cache = advOptions.cache; verbose = advOptions.verbose; @@ -246,6 +258,7 @@ export async function runWizard(): Promise { cache, verbose, writeMethods, + mockMode, }; displayConfigSummary(config); @@ -295,6 +308,7 @@ function displayConfigSummary(config: ServerConfig): void { console.log(` ${chalk.cyan('Latency:')} ${config.latency.min}-${config.latency.max}ms`); } + console.log(` ${chalk.cyan('Mock mode:')} ${config.mockMode ?? 'dev'}`); console.log(` ${chalk.cyan('Hot-reload:')} ${config.hotReload ? 'enabled' : 'disabled'}`); console.log(` ${chalk.cyan('Cache:')} ${config.cache ? 'enabled' : 'disabled'}`); console.log(` ${chalk.cyan('Verbose:')} ${config.verbose ? 'enabled' : 'disabled'}`); diff --git a/src/index.ts b/src/index.ts index a4a93f9..8b22aea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { ServerConfig } from './types/config'; +import { ServerConfig, MockMode } from './types/config'; import { startServer } from './server'; import { logger } from './utils/logger'; import { schemaCache } from './core/cache'; @@ -11,6 +11,18 @@ import { saveConfig } from './utils/configPersistence'; const program = new Command(); +/** + * Resolves the mock mode from CLI arg or MOCK_API_MODE env var. + * Exits with a clear error if the value is invalid. + */ +function resolveMockMode(cliValue?: string): MockMode { + const value = cliValue ?? process.env['MOCK_API_MODE']; + if (!value) return 'dev'; + if (value === 'strict' || value === 'dev') return value; + logger.error(`Invalid mockMode value: "${value}". Must be "strict" or "dev".`); + process.exit(1); +} + async function main() { // Check if user provided explicit CLI arguments const hasCliArgs = hasExplicitCliArgs(); @@ -38,6 +50,7 @@ async function main() { .option('--no-hot-reload', 'Disable hot-reload of type definitions') .option('--no-cache', 'Disable schema caching') .option('-v, --verbose', 'Enable verbose logging', false) + .option('--mock-mode ', 'Mock mode: "dev" enables all mock features (default), "strict" disables them') .option('--interactive', 'Force interactive mode') .action(async (options) => { // If --interactive flag is set, run wizard instead @@ -61,6 +74,9 @@ async function main() { latency = parseLatency(options.latency); } + // Resolve mockMode: CLI > ENV > default + const mockMode = resolveMockMode(options.mockMode); + // Build the configuration const config: ServerConfig = { typesDir, @@ -69,6 +85,7 @@ async function main() { hotReload: options.hotReload !== false, cache: options.cache !== false, verbose: options.verbose, + mockMode, }; // Configure the global cache diff --git a/src/server.ts b/src/server.ts index 9d25122..cd5e281 100644 --- a/src/server.ts +++ b/src/server.ts @@ -51,12 +51,13 @@ export function createServer(config: ServerConfig): Express { // Logging middleware app.use(requestLoggerMiddleware); - // Status override middleware - app.use(statusOverrideMiddleware); + // Mock-only middlewares — only mounted in 'dev' mode + if ((config.mockMode ?? 'dev') === 'dev') { + app.use(statusOverrideMiddleware); - // Latency middleware (if configured) - if (config.latency) { - app.use(latencyMiddleware(config.latency.min, config.latency.max)); + if (config.latency) { + app.use(latencyMiddleware(config.latency.min, config.latency.max)); + } } // Health route @@ -169,8 +170,9 @@ export function startServer( const server = app.listen(config.port, () => { logger.server(config.port); logger.info(`Types directory: ${config.typesDir}`); + logger.info(`Mock mode: ${config.mockMode ?? 'dev'}`); - if (config.latency) { + if (config.latency && (config.mockMode ?? 'dev') === 'dev') { logger.info( `Latency simulation: ${config.latency.min}-${config.latency.max}ms` ); diff --git a/src/types/config.ts b/src/types/config.ts index 0a39247..91c03be 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,3 +1,10 @@ +/** + * Mock mode controlling which mock-only features are active + * - `dev`: all mock features enabled (status override, artificial latency) + * - `strict`: clean REST simulation, mock features disabled + */ +export type MockMode = 'strict' | 'dev'; + /** * TS-Mock-Proxy server configuration */ @@ -23,6 +30,12 @@ export interface ServerConfig { /** Verbose mode for logging */ verbose: boolean; + /** + * Mock mode: 'dev' (default) enables all mock features; 'strict' disables them. + * Resolution order: CLI > MOCK_API_MODE env var > config file > default ('dev') + */ + mockMode?: MockMode; + /** Enable/disable write HTTP methods (POST, PUT, PATCH, DELETE). All enabled by default. */ writeMethods?: { post?: boolean; diff --git a/tests/integration/server.integration.test.ts b/tests/integration/server.integration.test.ts index 0f3083b..0a771fd 100644 --- a/tests/integration/server.integration.test.ts +++ b/tests/integration/server.integration.test.ts @@ -352,6 +352,62 @@ describe('Server integration', () => { }); }); + // --------------------------------------------------------------------------- + // mockMode: strict + // --------------------------------------------------------------------------- + describe('mockMode: strict', () => { + const strictApp = createServer({ + typesDir: FIXTURES_DIR, + port: 0, + hotReload: false, + cache: false, + verbose: false, + mockMode: 'strict', + }); + + it('ignores x-mock-status header and returns normal status', async () => { + const res = await request(strictApp) + .get('/api/users') + .set('x-mock-status', '503'); + expect(res.status).toBe(200); + }); + + it('ignores x-mock-status on write methods', async () => { + const res = await request(strictApp) + .post('/api/users') + .set('x-mock-status', '409') + .send({ name: 'Alice' }); + expect(res.status).toBe(201); + }); + + it('still serves normal mock data', async () => { + const res = await request(strictApp).get('/api/users'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // mockMode: dev (explicit) + // --------------------------------------------------------------------------- + describe('mockMode: dev', () => { + const devApp = createServer({ + typesDir: FIXTURES_DIR, + port: 0, + hotReload: false, + cache: false, + verbose: false, + mockMode: 'dev', + }); + + it('applies x-mock-status header', async () => { + const res = await request(devApp) + .get('/api/users') + .set('x-mock-status', '503'); + expect(res.status).toBe(503); + }); + }); + // --------------------------------------------------------------------------- // Disabled write methods // ---------------------------------------------------------------------------