From e2a050e3b45114518330f2056c703cf63b6185e1 Mon Sep 17 00:00:00 2001 From: Many0nne Date: Mon, 23 Mar 2026 22:32:27 +0100 Subject: [PATCH 1/4] =?UTF-8?q?Cr=C3=A9ation=20de=20la=20spec=20pour=20l'i?= =?UTF-8?q?mpl=C3=A9mentation=20de=20nouvels=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 9 - specs.md | 328 ++++++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 9 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 specs.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 3c52a00..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npm run test)" - ], - "deny": [], - "ask": [] - } -} diff --git a/specs.md b/specs.md new file mode 100644 index 0000000..90b07a4 --- /dev/null +++ b/specs.md @@ -0,0 +1,328 @@ +# Spec: Full REST Methods Support (POST / PUT / PATCH / DELETE) + +## Context + +TS-Mock-API currently only handles GET requests and returns mock data based on TypeScript interfaces. The goal is to extend the server to simulate a complete REST API, usable as a drop-in backend mock for frontend or integration development. The mock server should behave as closely as possible to a real REST API so that frontend developers can build and test against it without modifying their API client code. + +--- + +## Guiding Principles + +> "La logique de mock vient vraiment de la génération des données, mais le reste doit être conçu pour être utilisé dans une application réelle." + +This feature is **not** about adding more mock generation logic. It's about making the server behave like a real REST API so that a frontend application can be built and tested against it without modification. Specifically: + +- **Observability of changes**: a developer must be able to verify that data was actually modified. POST then GET must reflect the change. PATCH then GET must show the patched fields. +- **Standard HTTP semantics**: status codes, headers (Location, Allow), and method behaviors follow REST conventions, not convenience shortcuts. +- **Mock generation is the data source**: intermock + TypeScript interface constraints remain the single source of truth for generating data shapes. Write operations build on top of this — they don't bypass it. + +--- + +## Scope + +**In scope:** +- POST, PUT, PATCH, DELETE HTTP methods for all `@endpoint`-annotated interfaces +- Semi-stateful behavior (ID-aware in-memory store, survives within a session) +- Swagger/OpenAPI spec update (requestBody schemas per method) +- CLI wizard options to enable/disable write methods per method +- Integration into existing middleware chain (latency, x-mock-status, CORS, logging) +- Unit tests + integration tests + +**Out of scope:** +- Persistence across server restarts (intentionally stateless at restart) +- Request body runtime type validation (permissive — extra fields ignored) +- Per-interface method restrictions via JSDoc annotation +- Latency configuration per method (uniform latency for all methods) + +--- + +## Breaking Changes + +This is a **breaking change** for any consumer currently sending POST/PUT/PATCH/DELETE requests. Previously these fell through and returned a GET-like response. After this change: +- POST → 201 Created +- PUT/PATCH → 200 OK +- DELETE → 204 No Content (or forced via x-mock-status) +- Disabled methods → 405 Method Not Allowed + +Document in CHANGELOG. + +--- + +## Architecture: Semi-Stateful Write Store + +### Principle +The server becomes semi-stateful: write operations persist their effects **in memory** for the duration of the server session. The store is cleared on `/mock-reset` or hot-reload. + +### mockDataStore extension (`src/core/cache.ts`) +Extend the existing `mockDataStore` class with ID-aware methods: + +``` +getById(typeName, id) → Record | undefined +setById(typeName, id, obj) → void +deleteById(typeName, id) → boolean +getDeletedIds(typeName) → Set +markDeleted(typeName, id) → void +``` + +The collection pool (GET /users) and the individual store (GET /users/42) are **unified**: +- POST adds to both the collection pool and the ID store +- DELETE removes from both the collection pool and marks the ID as deleted +- PUT/PATCH updates both the pool entry and the ID store entry + +The pool is indexed by the item's ID field (detected as `id`, `uuid`, `_id` — first match in the mock object). + +### Write store stats +`mockDataStore.getWriteStats()` returns `{ [typeName]: { count: number, deletedCount: number } }`, exposed in `GET /health`. + +--- + +## HTTP Method Behaviors + +### POST `/{resources}` + +- **URL shape**: collection only (e.g., `/users`, `/api/v1/orders`) +- **POST `/col/{id}`** → 405 Method Not Allowed (semantically incorrect) +- **POST `/col/{id}/subcol`** → resolves to subcollection type (e.g., `Order`), creates an Order +- **Request body**: JSON (optional but expected). 400 if body is present but not valid JSON. +- **Response construction**: Generate a full mock from the TypeScript interface, then **override** mock fields with matching fields from the request body (body-over-mock merge). Extra fields not in the interface are silently ignored. +- **Type mismatch in body**: If a body field exists in the interface but carries the wrong type (e.g., `age: "foo"` when the interface expects `age: number`), the value is accepted as-is and stored without validation. No runtime type-checking is performed — the server is intentionally permissive. The resulting stored object may not conform to the TypeScript interface type; this is a known and accepted trade-off. +- **ID assignment**: Use the `id`/`uuid`/`_id` field from the generated mock (intermock handles type-correct generation). Body can override this field too. +- **State**: Store the result in the write store under `(typeName, id)`. Add to the collection pool. +- **Response**: 201 Created + the merged object + `Location: /{resources}/{generatedId}` header. + +### PUT `/{resources}/{id}` + +- **URL shape**: `col-id` only (e.g., `/users/42`) +- **Semantics**: Full replacement (upsert). Creates the resource if it doesn't exist (no prior POST required). +- **Request body**: JSON required. 400 if body is absent or not valid JSON. +- **Response construction**: Generate a full mock, override with body fields (body-over-mock). +- **State**: Store/overwrite in write store under `(typeName, id)`. Update pool entry if present. +- **Response**: 200 OK + the merged object. + +### PATCH `/{resources}/{id}` + +- **URL shape**: `col-id` only (e.g., `/users/42`) +- **Semantics**: Partial update. If ID exists in write store, merge patch onto stored object. If not, upsert (same as PUT). +- **Request body**: JSON required. 400 if body is absent or not valid JSON. +- **Response construction**: If stored: apply body fields on top of stored object. If not stored: generate mock + apply body (same as PUT). +- **State**: Store/overwrite in write store. Update pool entry. +- **Response**: 200 OK + the merged object. + +### DELETE `/{resources}/{id}` + +- **URL shape**: `col-id` only (e.g., `/users/42`) +- **State**: Mark ID as deleted in write store. Remove from collection pool. +- **Response**: 204 No Content (no body). +- **Subsequent GET `/users/42`**: 404 Not Found (ID is marked deleted). +- **Subsequent GET `/users`**: Pool no longer includes this item. + +--- + +## GET Behavior Changes (Statefulness) + +### GET `/{resources}/{id}` (single item) +- If ID is marked as **deleted** → 404 Not Found. +- If ID exists in **write store** → return stored object (result of POST/PUT/PATCH). +- If ID is **unknown** (never created) → **404 Not Found**. (Breaking change from current behavior which generated a random mock.) + +### GET `/{resources}` (collection) +- Pool is **dynamic**: reflects all POSTs (added) and DELETEs (removed). +- Pagination (`?page`, `?limit`, `?sort`, `?filter`) operates on the live pool. +- New items from POST are appended to the pool; deleted items are filtered out before pagination. +- Pool is still seeded with `POOL_SIZE` generated items on first request if empty. + +--- + +## Configuration: `ServerConfig` + +Add `writeMethods` field to `ServerConfig` (`src/types/config.ts`): + +```typescript +writeMethods?: { + post?: boolean; // default: true + put?: boolean; // default: true + patch?: boolean; // default: true + delete?: boolean; // default: true +}; +``` + +When a method is disabled (set to `false`): +- Return **405 Method Not Allowed** +- Include `Allow` header listing the currently enabled methods (e.g., `Allow: GET, POST, PUT`) + +--- + +## CLI Wizard (`src/cli/wizard.ts`) + +Add a step: **"Write methods configuration"** +- Option: Enable all write methods (default) +- Option: Read-only mode (disable all write methods) +- Option: Custom — toggle each method individually (POST / PUT / PATCH / DELETE) + +Store the selection in `ServerConfig.writeMethods`. + +--- + +## Middleware Integration + +### Latency (`src/middlewares/latency.ts`) +- Applied uniformly to all HTTP methods (no per-method config). +- No changes required; the global middleware already applies to all routes. + +### Status override (`src/middlewares/statusOverride.ts`) +- `x-mock-status` header works for all methods. +- For write methods: if `forcedStatus >= 400`, return the forced error (same logic as GET). +- Example: `x-mock-status: 409` on POST simulates a conflict. + +### CORS (`cors()`) +- Default `cors()` configuration handles OPTIONS preflight automatically. +- No explicit configuration needed for local dev use. + +### Logging (`src/middlewares/logger.ts`) +- No changes required. All methods are already logged. + +--- + +## Swagger / OpenAPI Spec (`src/core/swagger.ts`) + +For each `@endpoint`-annotated interface, generate operations for all active write methods: + +### POST `/{resources}` +```yaml +requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' # all fields, required as per interface +responses: + 201: + description: Created + headers: + Location: + schema: { type: string } + content: + application/json: + schema: + $ref: '#/components/schemas/User' +``` + +### PUT `/{resources}/{id}` +```yaml +requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' # all fields required (full replacement) +responses: + 200: { ... } +``` + +### PATCH `/{resources}/{id}` +```yaml +requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserPartial' # all fields optional (Partial) +responses: + 200: { ... } +``` + +### DELETE `/{resources}/{id}` +```yaml +responses: + 204: + description: No Content +``` + +**Swagger requestBody schema generation**: +- Reuse the existing TypeScript AST parser to extract interface fields and types. +- For PATCH: generate a `Partial` variant where all properties are `required: false`. +- For PUT/POST: all fields required as defined in the interface. + +--- + +## `/mock-reset` and Swagger "Rebuild Data" Button + +`POST /mock-reset` clears: +- `schemaCache` (schema object cache) +- `mockDataStore` pools (GET collection pools) +- `mockDataStore` write store (ID-aware entries) +- `mockDataStore` deleted ID sets + +This is the same behavior triggered by the "Rebuild Data" button in Swagger UI. + +--- + +## Hot-Reload Interaction + +When a `.ts` file changes in `typesDir`: +- The Swagger spec is regenerated (existing behavior). +- The write store entries for the **affected type** are cleared (new behavior). +- The collection pool for the affected type is cleared and will regenerate on next GET. + +--- + +## `GET /health` Changes + +Add `writeStore` stats to the response: + +```json +{ + "status": "ok", + "uptime": 123.4, + "cache": { ... }, + "writeStore": { + "User": { "count": 3, "deletedCount": 1 }, + "Order": { "count": 7, "deletedCount": 0 } + }, + "config": { ... } +} +``` + +--- + +## Edge Cases + +| Scenario | Behavior | +|---|---| +| `POST /users/42` (col-id) | 405 Method Not Allowed | +| `POST /users/42/orders` (col-id-col) | 201 — creates Order (subresource type) | +| `DELETE /users/99` (unknown ID) | 204 (stateless delete — idempotent) | +| `GET /users/99` (unknown, never created) | 404 Not Found | +| `PUT /users/99` (unknown ID) | Upsert — 200 + created object | +| `PATCH /users/99` (unknown ID) | Upsert — 200 + created object | +| `DELETE /users/42` then `GET /users/42` | 404 Not Found | +| `DELETE /users/42` then `GET /users` | Pool excludes item 42 | +| Body missing for POST/PUT/PATCH | 400 Bad Request | +| Body with extra fields (not in interface) | Fields silently ignored | +| `x-mock-status: 422` on PATCH | 422 returned (forced status, no body processing) | +| Disabled method (writeMethods.delete: false) | 405 + Allow header | + +--- + +## Testing Strategy + +### Unit tests (`tests/unit/`) +- `router.test.ts`: Test each method handler in isolation with mocked `findTypeForUrl`, `generateMockFromInterface`, and `mockDataStore`. +- Cases: successful create/update/delete, 400 for missing body, 404 for deleted ID, 405 for disabled method, 405 for POST on col-id URL. + +### Integration tests (`tests/integration/`) +- Full CRUD cycle: `POST /users` → `GET /users/{id}` (verify state) → `PUT /users/{id}` → `PATCH /users/{id}` → `DELETE /users/{id}` → `GET /users/{id}` (verify 404). +- Collection coherence: POST adds to `GET /users` pool, DELETE removes from pool. +- `x-mock-status` override on write methods. +- `/mock-reset` clears write store (verify state gone). +- Disabled methods return 405 with Allow header. +- Body merge: verify body fields appear in response. +- Location header present on 201. + +--- + +## Open Questions (Deferred) + +- Should `POST /users/42/orders` store the created Order with a reference to `userId: 42`? (Parent ID injection — not in scope for v1, could be addressed via body-override if the client sends it.) +- Should a future `@readonly` JSDoc annotation be supported to mark specific interfaces as GET-only? (Out of scope for v1 — use `writeMethods` config instead.) +- Should write store entries be exportable/importable (e.g., `GET /mock-state`, `POST /mock-state`) to seed tests with known state? (Potential future feature.) From b18d9de3dbed073dadf3821565ff4fc81c5c8861 Mon Sep 17 00:00:00 2001 From: Many0nne Date: Mon, 23 Mar 2026 23:32:49 +0100 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20impl=C3=A9mentation=20des=20options?= =?UTF-8?q?=20PUT/PATCH/POST/DELETE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 6 + src/cli/wizard.ts | 51 ++ src/core/cache.ts | 68 +++ src/core/router.ts | 502 ++++++++++++++++--- src/core/swagger.ts | 172 +++++-- src/server.ts | 8 +- src/types/config.ts | 8 + tests/integration/server.integration.test.ts | 291 ++++++++++- 8 files changed, 975 insertions(+), 131 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3ed3089..634ca62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "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", @@ -1813,6 +1814,7 @@ "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" } @@ -2297,6 +2299,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3240,6 +3243,7 @@ "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", @@ -4189,6 +4193,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6647,6 +6652,7 @@ "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 40acbab..1030e95 100644 --- a/src/cli/wizard.ts +++ b/src/cli/wizard.ts @@ -121,6 +121,7 @@ export async function runWizard(): Promise { let cache = savedConfig?.cache ?? true; let latency: { min: number; max: number } | undefined = savedConfig?.latency; let verbose = savedConfig?.verbose ?? false; + let writeMethods: ServerConfig['writeMethods'] = savedConfig?.writeMethods; if (advancedAnswer.showAdvanced) { const advOptions = await inquirer.prompt([ @@ -200,6 +201,40 @@ export async function runWizard(): Promise { } else { latency = undefined; } + + // Write methods configuration + const writeMethodsAnswer = await inquirer.prompt([ + { + type: 'list', + name: 'writeMode', + message: 'Write methods configuration:', + choices: [ + { name: 'Enable all write methods (POST, PUT, PATCH, DELETE)', value: 'all' }, + { name: 'Read-only mode (disable all write methods)', value: 'none' }, + { name: 'Custom — toggle each method individually', value: 'custom' }, + ], + default: 'all', + }, + ]); + + if (writeMethodsAnswer.writeMode === 'none') { + writeMethods = { post: false, put: false, patch: false, delete: false }; + } else if (writeMethodsAnswer.writeMode === 'custom') { + const customMethods = await inquirer.prompt([ + { type: 'confirm', name: 'post', message: 'Enable POST?', default: writeMethods?.post !== false }, + { type: 'confirm', name: 'put', message: 'Enable PUT?', default: writeMethods?.put !== false }, + { type: 'confirm', name: 'patch', message: 'Enable PATCH?', default: writeMethods?.patch !== false }, + { type: 'confirm', name: 'delete', message: 'Enable DELETE?', default: writeMethods?.delete !== false }, + ]); + writeMethods = { + post: customMethods.post, + put: customMethods.put, + patch: customMethods.patch, + delete: customMethods.delete, + }; + } else { + writeMethods = undefined; // all enabled (default) + } } // Build and display the configuration summary @@ -210,6 +245,7 @@ export async function runWizard(): Promise { hotReload, cache, verbose, + writeMethods, }; displayConfigSummary(config); @@ -263,6 +299,21 @@ function displayConfigSummary(config: ServerConfig): void { console.log(` ${chalk.cyan('Cache:')} ${config.cache ? 'enabled' : 'disabled'}`); console.log(` ${chalk.cyan('Verbose:')} ${config.verbose ? 'enabled' : 'disabled'}`); + const wm = config.writeMethods; + if (wm) { + const enabled = (['post', 'put', 'patch', 'delete'] as const) + .filter((m) => wm[m] !== false) + .map((m) => m.toUpperCase()); + const disabled = (['post', 'put', 'patch', 'delete'] as const) + .filter((m) => wm[m] === false) + .map((m) => m.toUpperCase()); + if (disabled.length > 0) { + console.log(` ${chalk.cyan('Write methods:')} enabled: ${enabled.join(', ') || 'none'} | disabled: ${disabled.join(', ')}`); + } + } else { + console.log(` ${chalk.cyan('Write methods:')} all enabled`); + } + console.log(chalk.gray('─'.repeat(50))); console.log(''); } diff --git a/src/core/cache.ts b/src/core/cache.ts index e79e039..3ebf5d4 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -132,6 +132,8 @@ interface MockEntry { export class MockDataStore { private singles: Map>> = new Map(); private pools: Map[]>> = new Map(); + private writeStore: Map>> = new Map(); + private deletedIds: Map> = new Map(); private key(typeName: string, filePath: string): string { return `${filePath}::${typeName}`; @@ -153,6 +155,59 @@ export class MockDataStore { this.pools.set(this.key(typeName, filePath), { data, createdAt: Date.now() }); } + // --- Write store methods --- + + getById(typeName: string, filePath: string, id: string): Record | undefined { + return this.writeStore.get(this.key(typeName, filePath))?.get(id); + } + + setById(typeName: string, filePath: string, id: string, obj: Record): void { + const k = this.key(typeName, filePath); + if (!this.writeStore.has(k)) { + this.writeStore.set(k, new Map()); + } + this.writeStore.get(k)!.set(id, obj); + } + + markDeleted(typeName: string, filePath: string, id: string): void { + const k = this.key(typeName, filePath); + if (!this.deletedIds.has(k)) { + this.deletedIds.set(k, new Set()); + } + this.deletedIds.get(k)!.add(id); + // Remove from write store if present + this.writeStore.get(k)?.delete(id); + } + + getDeletedIds(typeName: string, filePath: string): Set { + return this.deletedIds.get(this.key(typeName, filePath)) ?? new Set(); + } + + getAllWriteEntries(typeName: string, filePath: string): Map> { + return this.writeStore.get(this.key(typeName, filePath)) ?? new Map(); + } + + getWriteStats(): Record { + const stats: Record = {}; + for (const [k, map] of this.writeStore.entries()) { + // key format is "filePath::typeName" + const typeName = k.split('::').at(-1) ?? k; + stats[typeName] = { + count: map.size, + deletedCount: this.deletedIds.get(k)?.size ?? 0, + }; + } + for (const [k, ids] of this.deletedIds.entries()) { + const typeName = k.split('::').at(-1) ?? k; + if (!stats[typeName]) { + stats[typeName] = { count: 0, deletedCount: ids.size }; + } + } + return stats; + } + + // --- File invalidation & lifecycle --- + invalidateFile(filePath: string): void { let count = 0; for (const key of this.singles.keys()) { @@ -167,6 +222,17 @@ export class MockDataStore { count++; } } + for (const key of this.writeStore.keys()) { + if (key.startsWith(`${filePath}::`)) { + this.writeStore.delete(key); + count++; + } + } + for (const key of this.deletedIds.keys()) { + if (key.startsWith(`${filePath}::`)) { + this.deletedIds.delete(key); + } + } if (count > 0) { logger.info(`MockDataStore invalidated: ${count} entry/entries from ${filePath}`); } @@ -177,6 +243,8 @@ export class MockDataStore { const pools = this.pools.size; this.singles.clear(); this.pools.clear(); + this.writeStore.clear(); + this.deletedIds.clear(); logger.info(`MockDataStore cleared: ${singles} single(s), ${pools} pool(s)`); return { singles, pools }; } diff --git a/src/core/router.ts b/src/core/router.ts index b9f65da..805754b 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -1,8 +1,9 @@ import { Request, Response } from 'express'; import { ServerConfig, ApiErrorResponse } from '../types/config'; import { findTypeForUrl } from '../utils/typeMapping'; +import { parseUrlSegments, isIdSegment } from '../utils/pluralize'; import { generateMockFromInterface, generateMockArray } from './parser'; -import { schemaCache, mockDataStore } from './cache'; +import { mockDataStore } from './cache'; import { logger } from '../utils/logger'; import { parseQueryParams, @@ -11,6 +12,409 @@ import { POOL_SIZE, } from './queryProcessor'; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extracts the ID value from a URL (last segment that looks like an ID). */ +function extractIdFromUrl(url: string): string | undefined { + const segments = parseUrlSegments(url); + for (let i = segments.length - 1; i >= 0; i--) { + if (isIdSegment(segments[i]!)) { + return segments[i]; + } + } + return undefined; +} + +/** Returns the value of the first recognised ID field (id, uuid, _id) in a mock object. */ +function extractMockId(obj: Record): string | undefined { + for (const field of ['id', 'uuid', '_id']) { + if (obj[field] !== undefined) { + return String(obj[field]); + } + } + return undefined; +} + +/** Returns the name of the first recognised ID field present in the object, if any. */ +function findIdField(obj: Record): string | undefined { + for (const field of ['id', 'uuid', '_id']) { + if (field in obj) return field; + } + return undefined; +} + +/** Returns true when the given write method is enabled in the config. */ +function isWriteMethodEnabled(config: ServerConfig, method: string): boolean { + const wm = config.writeMethods; + if (!wm) return true; + switch (method) { + case 'post': return wm.post !== false; + case 'put': return wm.put !== false; + case 'patch': return wm.patch !== false; + case 'delete': return wm.delete !== false; + default: return true; + } +} + +/** Builds the Allow header value for collection endpoints. */ +function allowForCollection(config: ServerConfig): string { + const methods = ['GET']; + if (isWriteMethodEnabled(config, 'post')) methods.push('POST'); + return methods.join(', '); +} + +/** Builds the Allow header value for single-item endpoints. */ +function allowForSingle(config: ServerConfig): string { + const methods = ['GET']; + if (isWriteMethodEnabled(config, 'put')) methods.push('PUT'); + if (isWriteMethodEnabled(config, 'patch')) methods.push('PATCH'); + if (isWriteMethodEnabled(config, 'delete')) methods.push('DELETE'); + return methods.join(', '); +} + +/** + * Updates the pool entry for a given ID, or appends it if not already present. + * No-op when the pool has not been seeded yet. + */ +function updatePoolEntry( + typeName: string, + filePath: string, + id: string, + obj: Record +): void { + const pool = mockDataStore.getPool(typeName, filePath); + if (!pool) return; + const idx = pool.findIndex((item) => extractMockId(item) === id); + if (idx >= 0) { + pool[idx] = obj; + } else { + pool.push(obj); + } + mockDataStore.setPool(typeName, filePath, pool); +} + +/** + * Builds the "live pool" for a collection endpoint by merging the seeded pool + * with write-store entries, excluding deleted items and replacing overridden ones. + */ +function buildLivePool( + typeName: string, + filePath: string, + pool: Record[] +): Record[] { + const deletedIds = mockDataStore.getDeletedIds(typeName, filePath); + const writeEntries = mockDataStore.getAllWriteEntries(typeName, filePath); + + const fromPool = pool.filter((item) => { + const id = extractMockId(item); + if (id === undefined) return true; + if (deletedIds.has(id)) return false; + if (writeEntries.has(id)) return false; // write-store version takes precedence + return true; + }); + + const fromWriteStore = Array.from(writeEntries.values()).filter((item) => { + const id = extractMockId(item); + return id === undefined || !deletedIds.has(id); + }); + + return [...fromPool, ...fromWriteStore]; +} + +// --------------------------------------------------------------------------- +// Method handlers +// --------------------------------------------------------------------------- + +async function handleGet( + req: Request, + res: Response, + mapping: { typeName: string; isArray: boolean; filePath?: string }, + _config: ServerConfig, + filePath: string, + forcedStatus: number | undefined +): Promise { + if (mapping.isArray) { + // Sanitize query params + const sanitizedQuery: Record = {}; + for (const [key, value] of Object.entries(req.query)) { + if (typeof value === 'string' || value === undefined) { + sanitizedQuery[key] = value; + } else if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { + sanitizedQuery[key] = value as string[]; + } + } + + const parsed = parseQueryParams(sanitizedQuery); + if ('error' in parsed) { + res.status(400).json({ error: 'Invalid query parameters', message: parsed.error }); + return; + } + + // Seed pool on first request + let pool = mockDataStore.getPool(mapping.typeName, filePath); + if (!pool) { + pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE }); + mockDataStore.setPool(mapping.typeName, filePath, pool); + } + + const livePool = buildLivePool(mapping.typeName, filePath, pool); + + if (parsed.sort.length > 0 && livePool.length > 0) { + const firstItem = livePool[0]; + const allowedFields = new Set(firstItem ? Object.keys(firstItem) : []); + const sortError = validateSortFields(parsed.sort, allowedFields); + if (sortError) { + res.status(400).json({ error: 'Invalid sort parameter', message: sortError }); + return; + } + } + + res.status(forcedStatus || 200).json(applyPagination(livePool, parsed)); + } else { + // Single-item GET — stateful: only return if in write store, 404 otherwise + const urlId = extractIdFromUrl(req.path); + + if (urlId !== undefined) { + const deletedIds = mockDataStore.getDeletedIds(mapping.typeName, filePath); + if (deletedIds.has(urlId)) { + res.status(404).json({ + error: 'Not Found', + message: `Resource with ID ${urlId} has been deleted`, + }); + return; + } + + const stored = mockDataStore.getById(mapping.typeName, filePath, urlId); + if (stored) { + res.status(forcedStatus || 200).json(stored); + return; + } + } + + res.status(404).json({ + error: 'Not Found', + message: `Resource not found. Create it first with POST or PUT.`, + }); + } +} + +async function handlePost( + req: Request, + res: Response, + mapping: { typeName: string; isArray: boolean; filePath?: string }, + config: ServerConfig, + filePath: string, + forcedStatus: number | undefined +): Promise { + // POST on a single-item URL (col-id) is not semantically valid + if (!mapping.isArray) { + res + .status(405) + .set('Allow', allowForSingle(config)) + .json({ + error: 'Method Not Allowed', + message: 'POST is not allowed on a single resource URL. Use the collection endpoint.', + }); + return; + } + + if (!isWriteMethodEnabled(config, 'post')) { + res + .status(405) + .set('Allow', allowForCollection(config)) + .json({ error: 'Method Not Allowed', message: 'POST method is disabled' }); + return; + } + + const body = req.body as Record | undefined; + + const mock = generateMockFromInterface(filePath, mapping.typeName); + const merged: Record = + body && typeof body === 'object' && !Array.isArray(body) + ? { ...mock, ...body } + : { ...mock }; + + const id = extractMockId(merged); + + if (id !== undefined) { + mockDataStore.setById(mapping.typeName, filePath, id, merged); + // Append to pool (seed it first if needed) + const pool = mockDataStore.getPool(mapping.typeName, filePath) ?? []; + if (!mockDataStore.getPool(mapping.typeName, filePath)) { + mockDataStore.setPool(mapping.typeName, filePath, pool); + } + updatePoolEntry(mapping.typeName, filePath, id, merged); + } + + const basePath = req.path.replace(/\/$/, ''); + const location = id !== undefined ? `${basePath}/${id}` : basePath; + + res.status(forcedStatus || 201).set('Location', location).json(merged); +} + +async function handlePut( + req: Request, + res: Response, + mapping: { typeName: string; isArray: boolean; filePath?: string }, + config: ServerConfig, + filePath: string, + forcedStatus: number | undefined +): Promise { + if (mapping.isArray) { + res + .status(405) + .set('Allow', allowForCollection(config)) + .json({ + error: 'Method Not Allowed', + message: 'PUT is not allowed on a collection URL. Target a single resource.', + }); + return; + } + + if (!isWriteMethodEnabled(config, 'put')) { + res + .status(405) + .set('Allow', allowForSingle(config)) + .json({ error: 'Method Not Allowed', message: 'PUT method is disabled' }); + return; + } + + const body = req.body as Record | undefined; + if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length === 0) { + res.status(400).json({ error: 'Bad Request', message: 'Request body is required for PUT' }); + return; + } + + const urlId = extractIdFromUrl(req.path); + const mock = generateMockFromInterface(filePath, mapping.typeName); + const merged: Record = { ...mock, ...body }; + + // Ensure the stored ID matches the URL ID + if (urlId !== undefined) { + const idField = findIdField(merged) ?? 'id'; + const existing = merged[idField]; + merged[idField] = typeof existing === 'number' ? Number(urlId) : urlId; + } + + const id = urlId ?? extractMockId(merged); + if (id !== undefined) { + mockDataStore.setById(mapping.typeName, filePath, id, merged); + updatePoolEntry(mapping.typeName, filePath, id, merged); + } + + res.status(forcedStatus || 200).json(merged); +} + +async function handlePatch( + req: Request, + res: Response, + mapping: { typeName: string; isArray: boolean; filePath?: string }, + config: ServerConfig, + filePath: string, + forcedStatus: number | undefined +): Promise { + if (mapping.isArray) { + res + .status(405) + .set('Allow', allowForCollection(config)) + .json({ + error: 'Method Not Allowed', + message: 'PATCH is not allowed on a collection URL. Target a single resource.', + }); + return; + } + + if (!isWriteMethodEnabled(config, 'patch')) { + res + .status(405) + .set('Allow', allowForSingle(config)) + .json({ error: 'Method Not Allowed', message: 'PATCH method is disabled' }); + return; + } + + const body = req.body as Record | undefined; + if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length === 0) { + res.status(400).json({ error: 'Bad Request', message: 'Request body is required for PATCH' }); + return; + } + + const urlId = extractIdFromUrl(req.path); + + // If the resource was previously created, patch on top of stored object; otherwise upsert + let base: Record; + if (urlId !== undefined) { + const stored = mockDataStore.getById(mapping.typeName, filePath, urlId); + base = stored ?? generateMockFromInterface(filePath, mapping.typeName); + } else { + base = generateMockFromInterface(filePath, mapping.typeName); + } + + const merged: Record = { ...base, ...body }; + + // Ensure the stored ID matches the URL ID + if (urlId !== undefined) { + const idField = findIdField(merged) ?? 'id'; + const existing = base[idField]; + merged[idField] = typeof existing === 'number' ? Number(urlId) : urlId; + } + + const id = urlId ?? extractMockId(merged); + if (id !== undefined) { + mockDataStore.setById(mapping.typeName, filePath, id, merged); + updatePoolEntry(mapping.typeName, filePath, id, merged); + } + + res.status(forcedStatus || 200).json(merged); +} + +async function handleDelete( + req: Request, + res: Response, + mapping: { typeName: string; isArray: boolean; filePath?: string }, + config: ServerConfig, + filePath: string, + forcedStatus: number | undefined +): Promise { + if (mapping.isArray) { + res + .status(405) + .set('Allow', allowForCollection(config)) + .json({ + error: 'Method Not Allowed', + message: 'DELETE is not allowed on a collection URL. Target a single resource.', + }); + return; + } + + if (!isWriteMethodEnabled(config, 'delete')) { + res + .status(405) + .set('Allow', allowForSingle(config)) + .json({ error: 'Method Not Allowed', message: 'DELETE method is disabled' }); + return; + } + + const urlId = extractIdFromUrl(req.path); + + if (urlId !== undefined) { + mockDataStore.markDeleted(mapping.typeName, filePath, urlId); + // Remove from pool + const pool = mockDataStore.getPool(mapping.typeName, filePath); + if (pool) { + const newPool = pool.filter((item) => extractMockId(item) !== urlId); + mockDataStore.setPool(mapping.typeName, filePath, newPool); + } + } + + res.status(forcedStatus || 204).send(); +} + +// --------------------------------------------------------------------------- +// Main handler +// --------------------------------------------------------------------------- + /** * Dynamic route handler - Matches the URL with a type and generates the mock */ @@ -18,12 +422,11 @@ export function dynamicRouteHandler(config: ServerConfig) { return async (req: Request, res: Response): Promise => { try { const url = req.path; + const method = req.method.toUpperCase(); - // Search for the type corresponding to the URL const mapping = findTypeForUrl(url, config.typesDir); if (!mapping) { - // Type not found - return 404 const statusCode = res.locals.forcedStatus || 404; const notFoundError: ApiErrorResponse = { error: 'Type not found', @@ -38,11 +441,9 @@ export function dynamicRouteHandler(config: ServerConfig) { `Matched URL "${url}" -> Type "${mapping.typeName}" (array: ${mapping.isArray})` ); - // Check if the status is forced by the x-mock-status header const forcedStatus = res.locals.forcedStatus as number | undefined; if (forcedStatus && forcedStatus >= 400) { - // Return a forced error res.status(forcedStatus).json({ error: 'Forced error', message: `Status ${forcedStatus} forced via x-mock-status header`, @@ -50,11 +451,7 @@ export function dynamicRouteHandler(config: ServerConfig) { return; } - // Check the cache first (only for single objects, not arrays) - let mockData: Record | Record[]; - const { filePath } = mapping; - if (!filePath) { res.status(500).json({ error: 'Mock generation failed', @@ -63,80 +460,24 @@ export function dynamicRouteHandler(config: ServerConfig) { return; } - if (config.cache && !mapping.isArray) { - const cached = schemaCache.get(mapping.typeName, filePath); - - if (cached) { - mockData = cached.schema; - res.status(forcedStatus || 200).json(mockData); - return; - } - } - - // Generate the mock data - if (mapping.isArray) { - // Sanitize req.query: drop nested objects that parseQueryParams can't handle - const sanitizedQuery: Record = {}; - for (const [key, value] of Object.entries(req.query)) { - if (typeof value === 'string' || value === undefined) { - sanitizedQuery[key] = value; - } else if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { - sanitizedQuery[key] = value as string[]; - } - } - - // Parse and validate query parameters - const parsed = parseQueryParams(sanitizedQuery); - if ('error' in parsed) { - res.status(400).json({ error: 'Invalid query parameters', message: parsed.error }); - return; - } - - // Use stable pool from the data store; generate and cache on first request - let pool = mockDataStore.getPool(mapping.typeName, filePath); - if (!pool) { - pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE }); - mockDataStore.setPool(mapping.typeName, filePath, pool); - } - - // Validate sort fields against schema keys - if (parsed.sort.length > 0 && pool.length > 0) { - const firstItem = pool[0]; - const allowedFields = new Set(firstItem ? Object.keys(firstItem) : []); - const sortError = validateSortFields(parsed.sort, allowedFields); - if (sortError) { - res.status(400).json({ error: 'Invalid sort parameter', message: sortError }); - return; - } - } - - res.status(forcedStatus || 200).json(applyPagination(pool, parsed)); - return; - } else { - // Use stable single from the data store; generate and cache on first request - const stored = mockDataStore.getSingle(mapping.typeName, filePath); - if (stored) { - res.status(forcedStatus || 200).json(stored); - return; - } - mockData = generateMockFromInterface(filePath, mapping.typeName); - mockDataStore.setSingle(mapping.typeName, filePath, mockData as Record); - } - - // Store in schema cache too if enabled - if (config.cache && !mapping.isArray) { - schemaCache.set( - mapping.typeName, - filePath, - mockData as Record - ); + switch (method) { + case 'POST': + await handlePost(req, res, mapping, config, filePath, forcedStatus); + break; + case 'PUT': + await handlePut(req, res, mapping, config, filePath, forcedStatus); + break; + case 'PATCH': + await handlePatch(req, res, mapping, config, filePath, forcedStatus); + break; + case 'DELETE': + await handleDelete(req, res, mapping, config, filePath, forcedStatus); + break; + default: + await handleGet(req, res, mapping, config, filePath, forcedStatus); } - - // Return the mocked data - res.status(forcedStatus || 200).json(mockData); } catch (error) { logger.error('Error generating mock:', error); - const statusCode = res.locals.forcedStatus || 500; res.status(statusCode).json({ error: 'Mock generation failed', @@ -145,3 +486,4 @@ export function dynamicRouteHandler(config: ServerConfig) { } }; } + diff --git a/src/core/swagger.ts b/src/core/swagger.ts index 6400e86..afda0d5 100644 --- a/src/core/swagger.ts +++ b/src/core/swagger.ts @@ -27,14 +27,23 @@ interface OpenAPIPath { summary: string; description: string; parameters?: OpenAPIParameter[]; + requestBody?: { + required: boolean; + content: { + 'application/json': { + schema: unknown; + }; + }; + }; responses: { [statusCode: string]: { description: string; - content: { + content?: { 'application/json': { schema: unknown; }; }; + headers?: Record; }; }; } @@ -232,8 +241,27 @@ export function generateOpenAPISpec(config: ServerConfig): Record ({ + description, + content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } }, + }); + + // --- Collection path: GET + optional POST --- + const collectionPath: Record = { get: { summary: `List ${pluralize(interfaceName)}`, description: `Returns a paginated list of \`${interfaceName}\` objects. Supports filtering, sorting, and pagination via query parameters.`, @@ -256,60 +284,120 @@ export function generateOpenAPISpec(config: ServerConfig): Record; + + // --- Partial schema for PATCH (all fields optional) --- + const partialProperties: Record = {}; + for (const [field, schema] of Object.entries(properties)) { + partialProperties[field] = schema; + } + const partialSchemaName = `${interfaceName}Partial`; + schemas[partialSchemaName] = { type: 'object', properties: partialProperties }; - // GET /resources/{id} — single item - paths[singlePath] = { + // --- Single-item path: GET + optional PUT/PATCH/DELETE --- + const singleItemPath: Record = { get: { summary: `Get a single ${interfaceName}`, - description: `Returns a single \`${interfaceName}\` object by ID`, - parameters: [ - { - name: 'id', - in: 'path', - description: `ID of the ${interfaceName} (numeric, UUID, or MongoDB ObjectId)`, - required: true, - schema: { type: 'string' }, - }, - ], + description: `Returns a single \`${interfaceName}\` object by ID. Only available for resources created via POST or PUT.`, + parameters: [idParameter], responses: { '200': { description: 'Successful response', - content: { - 'application/json': { - schema: { $ref: `#/components/schemas/${interfaceName}` }, - }, - }, - }, - '404': { - description: 'Type not found', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/ErrorResponse' }, - }, - }, + content: { 'application/json': { schema: { $ref: `#/components/schemas/${interfaceName}` } } }, }, + '404': errorContent('Resource not found'), }, }, }; + + if (putEnabled) { + singleItemPath['put'] = { + summary: `Replace a ${interfaceName}`, + description: `Full replacement (upsert). Creates the resource if it does not exist. Body fields override the generated mock.`, + parameters: [idParameter], + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: `#/components/schemas/${interfaceName}` } }, + }, + }, + responses: { + '200': { + description: 'Updated', + content: { 'application/json': { schema: { $ref: `#/components/schemas/${interfaceName}` } } }, + }, + '400': errorContent('Missing or invalid request body'), + '405': errorContent('Method not allowed'), + }, + }; + } + + if (patchEnabled) { + singleItemPath['patch'] = { + summary: `Partially update a ${interfaceName}`, + description: `Partial update. Merges provided fields onto the stored object. If the resource does not exist, upserts it.`, + parameters: [idParameter], + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: `#/components/schemas/${partialSchemaName}` } }, + }, + }, + responses: { + '200': { + description: 'Updated', + content: { 'application/json': { schema: { $ref: `#/components/schemas/${interfaceName}` } } }, + }, + '400': errorContent('Missing or invalid request body'), + '405': errorContent('Method not allowed'), + }, + }; + } + + if (deleteEnabled) { + singleItemPath['delete'] = { + summary: `Delete a ${interfaceName}`, + description: `Marks the resource as deleted. Subsequent GET requests for this ID return 404.`, + parameters: [idParameter], + responses: { + '204': { description: 'No Content' }, + '405': errorContent('Method not allowed'), + }, + }; + } + + paths[singlePath] = singleItemPath as unknown as Record; }); // Add mock-reset endpoint diff --git a/src/server.ts b/src/server.ts index 632fa28..ab0c597 100644 --- a/src/server.ts +++ b/src/server.ts @@ -44,11 +44,13 @@ export function createServer(config: ServerConfig): Express { status: 'ok', uptime: process.uptime(), cache: schemaCache.getStats(), + writeStore: mockDataStore.getWriteStats(), config: { typesDir: config.typesDir, port: config.port, hotReload: config.hotReload, cache: config.cache, + writeMethods: config.writeMethods, }, }); }); @@ -165,7 +167,11 @@ export function startServer( if (config.hotReload) { watcher = startFileWatcher(config.typesDir, (filePath) => { logger.info(`Type file updated: ${filePath}`); - + + // Clear cached data for the affected file (pools, singles, write store) + mockDataStore.invalidateFile(filePath); + schemaCache.invalidateFile(filePath); + // Regenerate Swagger spec to include new endpoints const newSwaggerSpec = generateOpenAPISpec(config); app.locals.swaggerSpec = newSwaggerSpec; diff --git a/src/types/config.ts b/src/types/config.ts index e206010..dae66c4 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -22,6 +22,14 @@ export interface ServerConfig { /** Verbose mode for logging */ verbose: boolean; + + /** Enable/disable write HTTP methods (POST, PUT, PATCH, DELETE). All enabled by default. */ + writeMethods?: { + post?: boolean; + put?: boolean; + patch?: boolean; + delete?: boolean; + }; } /** diff --git a/tests/integration/server.integration.test.ts b/tests/integration/server.integration.test.ts index 695dff6..6267922 100644 --- a/tests/integration/server.integration.test.ts +++ b/tests/integration/server.integration.test.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import request from 'supertest'; import { createServer } from '../../src/server'; import { invalidateTypeMap } from '../../src/utils/typeMapping'; +import { mockDataStore } from '../../src/core/cache'; import { ServerConfig } from '../../src/types/config'; const FIXTURES_DIR = path.join(__dirname, '../fixtures/types'); @@ -18,10 +19,13 @@ describe('Server integration', () => { const app = createServer(testConfig); beforeEach(() => { - // Ensure type map is rebuilt for each test invalidateTypeMap(); + mockDataStore.clear(); }); + // --------------------------------------------------------------------------- + // GET /health + // --------------------------------------------------------------------------- describe('GET /health', () => { it('returns 200 with status ok', async () => { const res = await request(app).get('/health'); @@ -29,9 +33,13 @@ describe('Server integration', () => { expect(res.body.status).toBe('ok'); expect(res.body).toHaveProperty('uptime'); expect(res.body).toHaveProperty('cache'); + expect(res.body).toHaveProperty('writeStore'); }); }); + // --------------------------------------------------------------------------- + // GET collection + // --------------------------------------------------------------------------- describe('GET /api/users', () => { it('returns 200 with an array of users', async () => { const res = await request(app).get('/api/users'); @@ -47,7 +55,6 @@ describe('Server integration', () => { expect(first).toHaveProperty('id'); expect(first).toHaveProperty('name'); expect(first).toHaveProperty('email'); - expect(first.id).toBeDefined(); expect(typeof first.name).toBe('string'); expect(typeof first.email).toBe('string'); }); @@ -65,16 +72,33 @@ describe('Server integration', () => { }); }); + // --------------------------------------------------------------------------- + // GET single item (stateful — requires prior creation) + // --------------------------------------------------------------------------- describe('GET /api/users/:id', () => { - it('returns 200 with a single user object', async () => { - const res = await request(app).get('/api/users/1'); - expect(res.status).toBe(200); - expect(res.body).toHaveProperty('id'); - expect(res.body).toHaveProperty('name'); - expect(res.body).toHaveProperty('email'); + it('returns 404 for an unknown ID (never created)', async () => { + const res = await request(app).get('/api/users/99999'); + expect(res.status).toBe(404); + }); + + it('returns 200 after POST creates the resource', async () => { + const postRes = await request(app) + .post('/api/users') + .send({ name: 'Alice', email: 'alice@example.com' }); + expect(postRes.status).toBe(201); + + const id = postRes.body.id; + const getRes = await request(app).get(`/api/users/${id}`); + expect(getRes.status).toBe(200); + expect(getRes.body.id).toBe(id); + expect(getRes.body.name).toBe('Alice'); + expect(getRes.body.email).toBe('alice@example.com'); }); }); + // --------------------------------------------------------------------------- + // GET unknown route + // --------------------------------------------------------------------------- describe('GET unknown route', () => { it('returns 404 for unknown resources', async () => { const res = await request(app).get('/api/unknownresource'); @@ -83,6 +107,9 @@ describe('Server integration', () => { }); }); + // --------------------------------------------------------------------------- + // x-mock-status header + // --------------------------------------------------------------------------- describe('x-mock-status header', () => { it('forces the response status code to 503', async () => { const res = await request(app) @@ -90,5 +117,253 @@ describe('Server integration', () => { .set('x-mock-status', '503'); expect(res.status).toBe(503); }); + + it('forces the status code on write methods', async () => { + const res = await request(app) + .post('/api/users') + .set('x-mock-status', '409') + .send({ name: 'Alice' }); + expect(res.status).toBe(409); + }); + }); + + // --------------------------------------------------------------------------- + // POST + // --------------------------------------------------------------------------- + describe('POST /api/users', () => { + it('returns 201 with the created resource', async () => { + const res = await request(app) + .post('/api/users') + .send({ name: 'Bob', email: 'bob@example.com' }); + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('id'); + expect(res.body.name).toBe('Bob'); + expect(res.body.email).toBe('bob@example.com'); + }); + + it('returns a Location header pointing to the new resource', async () => { + const res = await request(app) + .post('/api/users') + .send({ name: 'Carol' }); + expect(res.status).toBe(201); + expect(res.headers['location']).toMatch(/\/api\/users\//); + }); + + it('works with no body (generates full mock)', async () => { + const res = await request(app).post('/api/users'); + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('id'); + }); + + it('returns 405 when POST is sent to a single-resource URL', async () => { + const res = await request(app).post('/api/users/42'); + expect(res.status).toBe(405); + }); + + it('new resource appears in subsequent GET /api/users', async () => { + const postRes = await request(app) + .post('/api/users') + .send({ name: 'Dave' }); + const id = postRes.body.id; + + const listRes = await request(app).get('/api/users?pageSize=100'); + const ids = listRes.body.data.map((u: { id: unknown }) => String(u.id)); + expect(ids).toContain(String(id)); + }); + }); + + // --------------------------------------------------------------------------- + // PUT + // --------------------------------------------------------------------------- + describe('PUT /api/users/:id', () => { + it('returns 200 with the replaced resource (upsert)', async () => { + const res = await request(app) + .put('/api/users/42') + .send({ name: 'Eve', email: 'eve@example.com' }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('Eve'); + expect(res.body.email).toBe('eve@example.com'); + }); + + it('subsequent GET returns the PUT body', async () => { + await request(app) + .put('/api/users/42') + .send({ name: 'Frank', email: 'frank@example.com' }); + + const res = await request(app).get('/api/users/42'); + expect(res.status).toBe(200); + expect(res.body.name).toBe('Frank'); + }); + + it('returns 400 when body is absent', async () => { + const res = await request(app).put('/api/users/42'); + expect(res.status).toBe(400); + }); + + it('returns 405 when PUT is sent to a collection URL', async () => { + const res = await request(app) + .put('/api/users') + .send({ name: 'Frank' }); + expect(res.status).toBe(405); + }); + }); + + // --------------------------------------------------------------------------- + // PATCH + // --------------------------------------------------------------------------- + describe('PATCH /api/users/:id', () => { + it('patches an existing resource', async () => { + await request(app) + .put('/api/users/10') + .send({ name: 'Grace', email: 'grace@example.com' }); + + const res = await request(app) + .patch('/api/users/10') + .send({ name: 'Grace Updated' }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('Grace Updated'); + expect(res.body.email).toBe('grace@example.com'); + }); + + it('upserts when ID does not exist', async () => { + const res = await request(app) + .patch('/api/users/999') + .send({ name: 'Henry' }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('Henry'); + }); + + it('returns 400 when body is absent', async () => { + const res = await request(app).patch('/api/users/10'); + expect(res.status).toBe(400); + }); + + it('returns 405 when PATCH is sent to a collection URL', async () => { + const res = await request(app) + .patch('/api/users') + .send({ name: 'Henry' }); + expect(res.status).toBe(405); + }); + }); + + // --------------------------------------------------------------------------- + // DELETE + // --------------------------------------------------------------------------- + describe('DELETE /api/users/:id', () => { + it('returns 204 with no body', async () => { + await request(app).put('/api/users/55').send({ name: 'Iris' }); + const res = await request(app).delete('/api/users/55'); + expect(res.status).toBe(204); + expect(res.body).toEqual({}); + }); + + it('subsequent GET returns 404 after DELETE', async () => { + await request(app).put('/api/users/56').send({ name: 'Jack' }); + await request(app).delete('/api/users/56'); + const res = await request(app).get('/api/users/56'); + expect(res.status).toBe(404); + }); + + it('deleted resource disappears from GET /api/users collection', async () => { + const postRes = await request(app).post('/api/users').send({ name: 'Kate' }); + const id = String(postRes.body.id); + + await request(app).delete(`/api/users/${id}`); + + const listRes = await request(app).get('/api/users?pageSize=100'); + const ids = listRes.body.data.map((u: { id: unknown }) => String(u.id)); + expect(ids).not.toContain(id); + }); + + it('is idempotent — deleting unknown ID returns 204', async () => { + const res = await request(app).delete('/api/users/99999'); + expect(res.status).toBe(204); + }); + + it('returns 405 when DELETE is sent to a collection URL', async () => { + const res = await request(app).delete('/api/users'); + expect(res.status).toBe(405); + }); + }); + + // --------------------------------------------------------------------------- + // Full CRUD cycle + // --------------------------------------------------------------------------- + describe('Full CRUD cycle', () => { + it('POST → GET → PATCH → GET → DELETE → GET', async () => { + // Create + const created = await request(app) + .post('/api/users') + .send({ name: 'Lifecycle', email: 'lc@example.com' }); + expect(created.status).toBe(201); + const id = created.body.id; + + // Read + const read1 = await request(app).get(`/api/users/${id}`); + expect(read1.status).toBe(200); + expect(read1.body.name).toBe('Lifecycle'); + + // Patch + const patched = await request(app) + .patch(`/api/users/${id}`) + .send({ name: 'Updated' }); + expect(patched.status).toBe(200); + expect(patched.body.name).toBe('Updated'); + + // Read again + const read2 = await request(app).get(`/api/users/${id}`); + expect(read2.status).toBe(200); + expect(read2.body.name).toBe('Updated'); + + // Delete + const deleted = await request(app).delete(`/api/users/${id}`); + expect(deleted.status).toBe(204); + + // Read after delete + const read3 = await request(app).get(`/api/users/${id}`); + expect(read3.status).toBe(404); + }); + }); + + // --------------------------------------------------------------------------- + // mock-reset + // --------------------------------------------------------------------------- + describe('POST /mock-reset', () => { + it('clears the write store', async () => { + await request(app).put('/api/users/77').send({ name: 'Temp' }); + const before = await request(app).get('/api/users/77'); + expect(before.status).toBe(200); + + await request(app).post('/mock-reset'); + + const after = await request(app).get('/api/users/77'); + expect(after.status).toBe(404); + }); + }); + + // --------------------------------------------------------------------------- + // Disabled write methods + // --------------------------------------------------------------------------- + describe('Disabled write methods', () => { + const readOnlyConfig: ServerConfig = { + typesDir: FIXTURES_DIR, + port: 0, + hotReload: false, + cache: false, + verbose: false, + writeMethods: { post: false, put: false, patch: false, delete: false }, + }; + const readOnlyApp = createServer(readOnlyConfig); + + it('returns 405 for POST when disabled', async () => { + const res = await request(readOnlyApp).post('/api/users').send({ name: 'X' }); + expect(res.status).toBe(405); + expect(res.headers['allow']).toBeDefined(); + }); + + it('returns 405 for DELETE when disabled', async () => { + const res = await request(readOnlyApp).delete('/api/users/1'); + expect(res.status).toBe(405); + }); }); }); From d1780dae3e7cdf476a20bcf0b7b4a4c34bec9e6f Mon Sep 17 00:00:00 2001 From: Many0nne Date: Tue, 24 Mar 2026 00:41:19 +0100 Subject: [PATCH 3/4] =?UTF-8?q?seed=20tout=20au=20d=C3=A9marrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/router.ts | 13 +++++++++- src/server.ts | 25 +++++++++++++++++++- tests/integration/server.integration.test.ts | 17 ++++++++++--- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/core/router.ts b/src/core/router.ts index 805754b..d38802f 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -186,16 +186,27 @@ async function handleGet( return; } + // Check write store (highest priority — reflects PUT/PATCH) const stored = mockDataStore.getById(mapping.typeName, filePath, urlId); if (stored) { res.status(forcedStatus || 200).json(stored); return; } + + // Fall back to the seeded pool + const pool = mockDataStore.getPool(mapping.typeName, filePath); + if (pool) { + const poolItem = pool.find((item) => extractMockId(item) === urlId); + if (poolItem) { + res.status(forcedStatus || 200).json(poolItem); + return; + } + } } res.status(404).json({ error: 'Not Found', - message: `Resource not found. Create it first with POST or PUT.`, + message: `Resource not found`, }); } } diff --git a/src/server.ts b/src/server.ts index ab0c597..9d25122 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,8 +10,26 @@ import { dynamicRouteHandler } from './core/router'; import { startFileWatcher } from './utils/fileWatcher'; import { schemaCache, mockDataStore } from './core/cache'; import { generateOpenAPISpec } from './core/swagger'; +import { buildTypeMap } from './utils/typeMapping'; +import { generateMockArray } from './core/parser'; +import { POOL_SIZE } from './core/queryProcessor'; import type { FSWatcher } from 'chokidar'; +/** + * Seeds all collection pools for every @endpoint interface found in typesDir. + * Already-seeded pools are left untouched. + */ +function seedAllPools(config: ServerConfig): void { + const typeMap = buildTypeMap(config.typesDir); + typeMap.forEach((filePath, typeName) => { + if (!mockDataStore.getPool(typeName, filePath)) { + const pool = generateMockArray(filePath, typeName, { arrayLength: POOL_SIZE }); + mockDataStore.setPool(typeName, filePath, pool); + logger.debug(`Pool seeded: ${typeName} (${pool.length} items)`); + } + }); +} + /** * Creates and configures the Express server */ @@ -22,6 +40,9 @@ export function createServer(config: ServerConfig): Express { let swaggerSpec = generateOpenAPISpec(config); app.locals.swaggerSpec = swaggerSpec; + // Eagerly seed all collection pools so GET /{col}/{id} works immediately + seedAllPools(config); + // Global middlewares app.use(cors()); app.use(express.json()); @@ -108,6 +129,7 @@ window.addEventListener('load', function () { app.post('/mock-reset', (_req, res) => { const mockCleared = mockDataStore.clear(); schemaCache.clear(); + seedAllPools(config); res.json({ message: 'Mock data store cleared', cleared: mockCleared }); }); @@ -172,9 +194,10 @@ export function startServer( mockDataStore.invalidateFile(filePath); schemaCache.invalidateFile(filePath); - // Regenerate Swagger spec to include new endpoints + // Regenerate Swagger spec and re-seed pools for affected types const newSwaggerSpec = generateOpenAPISpec(config); app.locals.swaggerSpec = newSwaggerSpec; + seedAllPools(config); logger.success('Swagger spec regenerated with updated endpoints'); }); } diff --git a/tests/integration/server.integration.test.ts b/tests/integration/server.integration.test.ts index 6267922..0f3083b 100644 --- a/tests/integration/server.integration.test.ts +++ b/tests/integration/server.integration.test.ts @@ -73,14 +73,25 @@ describe('Server integration', () => { }); // --------------------------------------------------------------------------- - // GET single item (stateful — requires prior creation) + // GET single item // --------------------------------------------------------------------------- describe('GET /api/users/:id', () => { - it('returns 404 for an unknown ID (never created)', async () => { - const res = await request(app).get('/api/users/99999'); + it('returns 404 for a truly unknown ID (UUID never in pool)', async () => { + // UUIDs are never generated for id: number, so this will never be in the pool + const res = await request(app).get('/api/users/00000000-0000-0000-0000-000000000000'); expect(res.status).toBe(404); }); + it('returns 200 for a pool item (seeded at startup)', async () => { + const listRes = await request(app).get('/api/users'); + expect(listRes.status).toBe(200); + const firstId = listRes.body.data[0].id; + + const getRes = await request(app).get(`/api/users/${firstId}`); + expect(getRes.status).toBe(200); + expect(getRes.body.id).toBe(firstId); + }); + it('returns 200 after POST creates the resource', async () => { const postRes = await request(app) .post('/api/users') From 1dff933e8a0b7e3f3742b49abd6e41e2c020d76a Mon Sep 17 00:00:00 2001 From: Many0nne Date: Tue, 24 Mar 2026 14:50:52 +0100 Subject: [PATCH 4/4] refactor: remove singles store, fix patch base resolution and live pool ordering --- src/cli/wizard.ts | 2 ++ src/core/cache.ts | 29 ++++++----------------------- src/core/router.ts | 28 ++++++++++++---------------- src/core/swagger.ts | 1 - src/types/config.ts | 2 ++ tests/core/cache.test.ts | 38 +++++++------------------------------- 6 files changed, 29 insertions(+), 71 deletions(-) diff --git a/src/cli/wizard.ts b/src/cli/wizard.ts index 1030e95..15af76d 100644 --- a/src/cli/wizard.ts +++ b/src/cli/wizard.ts @@ -309,6 +309,8 @@ function displayConfigSummary(config: ServerConfig): void { .map((m) => m.toUpperCase()); if (disabled.length > 0) { console.log(` ${chalk.cyan('Write methods:')} enabled: ${enabled.join(', ') || 'none'} | disabled: ${disabled.join(', ')}`); + } else { + console.log(` ${chalk.cyan('Write methods:')} all enabled`); } } else { console.log(` ${chalk.cyan('Write methods:')} all enabled`); diff --git a/src/core/cache.ts b/src/core/cache.ts index 3ebf5d4..a6f58fd 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -122,7 +122,7 @@ export const schemaCache = new SchemaCache(); /** * Always-on data store for stable mock data across requests. - * Caches both single object mocks and array pools independently of config.cache. + * Manages array pools, per-ID write entries, and deleted-ID tracking. */ interface MockEntry { data: T; @@ -130,7 +130,6 @@ interface MockEntry { } export class MockDataStore { - private singles: Map>> = new Map(); private pools: Map[]>> = new Map(); private writeStore: Map>> = new Map(); private deletedIds: Map> = new Map(); @@ -139,14 +138,6 @@ export class MockDataStore { return `${filePath}::${typeName}`; } - getSingle(typeName: string, filePath: string): Record | undefined { - return this.singles.get(this.key(typeName, filePath))?.data; - } - - setSingle(typeName: string, filePath: string, data: Record): void { - this.singles.set(this.key(typeName, filePath), { data, createdAt: Date.now() }); - } - getPool(typeName: string, filePath: string): Record[] | undefined { return this.pools.get(this.key(typeName, filePath))?.data; } @@ -210,12 +201,6 @@ export class MockDataStore { invalidateFile(filePath: string): void { let count = 0; - for (const key of this.singles.keys()) { - if (key.startsWith(`${filePath}::`)) { - this.singles.delete(key); - count++; - } - } for (const key of this.pools.keys()) { if (key.startsWith(`${filePath}::`)) { this.pools.delete(key); @@ -238,19 +223,17 @@ export class MockDataStore { } } - clear(): { singles: number; pools: number } { - const singles = this.singles.size; + clear(): { pools: number } { const pools = this.pools.size; - this.singles.clear(); this.pools.clear(); this.writeStore.clear(); this.deletedIds.clear(); - logger.info(`MockDataStore cleared: ${singles} single(s), ${pools} pool(s)`); - return { singles, pools }; + logger.info(`MockDataStore cleared: ${pools} pool(s)`); + return { pools }; } - getStats(): { singles: number; pools: number } { - return { singles: this.singles.size, pools: this.pools.size }; + getStats(): { pools: number } { + return { pools: this.pools.size }; } } diff --git a/src/core/router.ts b/src/core/router.ts index d38802f..790a5ca 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -11,6 +11,7 @@ import { applyPagination, POOL_SIZE, } from './queryProcessor'; +import type { WriteMethod } from '../types/config'; // --------------------------------------------------------------------------- // Helpers @@ -46,16 +47,10 @@ function findIdField(obj: Record): string | undefined { } /** Returns true when the given write method is enabled in the config. */ -function isWriteMethodEnabled(config: ServerConfig, method: string): boolean { +function isWriteMethodEnabled(config: ServerConfig, method: WriteMethod): boolean { const wm = config.writeMethods; if (!wm) return true; - switch (method) { - case 'post': return wm.post !== false; - case 'put': return wm.put !== false; - case 'patch': return wm.patch !== false; - case 'delete': return wm.delete !== false; - default: return true; - } + return wm[method] !== false; } /** Builds the Allow header value for collection endpoints. */ @@ -120,7 +115,7 @@ function buildLivePool( return id === undefined || !deletedIds.has(id); }); - return [...fromPool, ...fromWriteStore]; + return [...fromWriteStore, ...fromPool]; } // --------------------------------------------------------------------------- @@ -173,7 +168,7 @@ async function handleGet( res.status(forcedStatus || 200).json(applyPagination(livePool, parsed)); } else { - // Single-item GET — stateful: only return if in write store, 404 otherwise + // Single-item GET — checks deletedIds, then write store, then seeded pool const urlId = extractIdFromUrl(req.path); if (urlId !== undefined) { @@ -251,11 +246,6 @@ async function handlePost( if (id !== undefined) { mockDataStore.setById(mapping.typeName, filePath, id, merged); - // Append to pool (seed it first if needed) - const pool = mockDataStore.getPool(mapping.typeName, filePath) ?? []; - if (!mockDataStore.getPool(mapping.typeName, filePath)) { - mockDataStore.setPool(mapping.typeName, filePath, pool); - } updatePoolEntry(mapping.typeName, filePath, id, merged); } @@ -357,7 +347,13 @@ async function handlePatch( let base: Record; if (urlId !== undefined) { const stored = mockDataStore.getById(mapping.typeName, filePath, urlId); - base = stored ?? generateMockFromInterface(filePath, mapping.typeName); + if (stored) { + base = stored; + } else { + const pool = mockDataStore.getPool(mapping.typeName, filePath); + const poolItem = pool?.find((item) => extractMockId(item) === urlId); + base = poolItem ?? generateMockFromInterface(filePath, mapping.typeName); + } } else { base = generateMockFromInterface(filePath, mapping.typeName); } diff --git a/src/core/swagger.ts b/src/core/swagger.ts index afda0d5..a710649 100644 --- a/src/core/swagger.ts +++ b/src/core/swagger.ts @@ -419,7 +419,6 @@ export function generateOpenAPISpec(config: ServerConfig): Record { store = new MockDataStore(); }); - describe('getSingle and setSingle', () => { - it('should store and retrieve a single', () => { - const data = { id: 1, name: 'Alice' }; - store.setSingle('User', '/path/user.ts', data); - expect(store.getSingle('User', '/path/user.ts')).toEqual(data); - }); - - it('should return undefined for non-existent single', () => { - expect(store.getSingle('User', '/path/user.ts')).toBeUndefined(); - }); - - it('should scope by filePath and typeName', () => { - store.setSingle('User', '/path/a.ts', { id: 1 }); - store.setSingle('User', '/path/b.ts', { id: 2 }); - expect(store.getSingle('User', '/path/a.ts')).toEqual({ id: 1 }); - expect(store.getSingle('User', '/path/b.ts')).toEqual({ id: 2 }); - }); - }); - describe('getPool and setPool', () => { it('should store and retrieve a pool', () => { const pool = [{ id: 1 }, { id: 2 }]; @@ -200,32 +181,28 @@ describe('MockDataStore', () => { describe('clear', () => { it('should clear all entries and return counts', () => { - store.setSingle('User', '/path/user.ts', { id: 1 }); store.setPool('Product', '/path/product.ts', [{ id: 2 }]); const result = store.clear(); - expect(result).toEqual({ singles: 1, pools: 1 }); - expect(store.getSingle('User', '/path/user.ts')).toBeUndefined(); + expect(result).toEqual({ pools: 1 }); expect(store.getPool('Product', '/path/product.ts')).toBeUndefined(); }); it('should return zero counts when already empty', () => { - expect(store.clear()).toEqual({ singles: 0, pools: 0 }); + expect(store.clear()).toEqual({ pools: 0 }); }); }); describe('invalidateFile', () => { - it('should remove singles and pools for a given file', () => { - store.setSingle('User', '/path/types.ts', { id: 1 }); + it('should remove pools for a given file', () => { store.setPool('User', '/path/types.ts', [{ id: 1 }]); - store.setSingle('Product', '/path/other.ts', { id: 2 }); + store.setPool('Product', '/path/other.ts', [{ id: 2 }]); store.invalidateFile('/path/types.ts'); - expect(store.getSingle('User', '/path/types.ts')).toBeUndefined(); expect(store.getPool('User', '/path/types.ts')).toBeUndefined(); - expect(store.getSingle('Product', '/path/other.ts')).toEqual({ id: 2 }); + expect(store.getPool('Product', '/path/other.ts')).toEqual([{ id: 2 }]); }); it('should not throw when file has no entries', () => { @@ -235,13 +212,12 @@ describe('MockDataStore', () => { describe('getStats', () => { it('should return correct counts', () => { - store.setSingle('User', '/path/user.ts', { id: 1 }); store.setPool('Product', '/path/product.ts', [{ id: 2 }]); - expect(store.getStats()).toEqual({ singles: 1, pools: 1 }); + expect(store.getStats()).toEqual({ pools: 1 }); }); it('should return zeros when empty', () => { - expect(store.getStats()).toEqual({ singles: 0, pools: 0 }); + expect(store.getStats()).toEqual({ pools: 0 }); }); }); });