From 21a989c012bfa5a1b20f6968093a389021e9d980 Mon Sep 17 00:00:00 2001 From: Many0nne Date: Sat, 7 Mar 2026 14:09:33 +0100 Subject: [PATCH 1/3] add pagination / filtering / sorting for the plural endpoints --- CLAUDE.md | 54 ++++++ README.md | 82 +++++++- src/core/queryProcessor.ts | 266 ++++++++++++++++++++++++++ src/core/router.ts | 34 +++- tests/core/queryProcessor.test.ts | 299 ++++++++++++++++++++++++++++++ 5 files changed, 727 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/core/queryProcessor.ts create mode 100644 tests/core/queryProcessor.test.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a56736 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # Start dev server with nodemon + tsx (interactive wizard) +npm run build # Compile TypeScript to dist/ +npm start # Run compiled server (dist/index.js) +npm test # Run all tests with Jest +npm run test:watch # Watch mode +npm run test:coverage # Coverage report +npm run clean # Remove dist/ +``` + +Run a single test file: +```bash +npm test -- constraintExtractor.test.ts +npm test -- --testPathPattern=pluralize +``` + +TypeScript is strict (`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`, etc.). Tests live in `tests/` and are excluded from `tsconfig.json`. + +## Architecture + +**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)`. + +**Request lifecycle** (`src/server.ts` → `src/core/router.ts`): +1. Express middleware chain: CORS → JSON → logger → `statusOverride` → optional latency +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` +5. Results cached in `schemaCache` (single objects only, not arrays) + +**URL → Interface resolution** (`src/utils/typeMapping.ts` + `src/utils/pluralize.ts`): +- Scans `typesDir` recursively for `.ts` files +- Only interfaces with `// @endpoint` (or in a JSDoc block containing `@endpoint`) are exposed +- URL last segment is converted to PascalCase; if a matching interface exists directly (e.g. `Users` for `/users`), it wins as a non-array route +- Otherwise, the segment is singularized via the `pluralize` library (`users` → `User`) and `isArray: true` is set + +**Mock generation** (`src/core/parser.ts`): +- Uses `intermock` with `isFixedMode: false` for random data +- 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`) + +**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` diff --git a/README.md b/README.md index 367620d..ce21202 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,9 @@ Options: curl http://localhost:8080/user # → {"id": 482, "name": "John Doe", "email": "john.d@gmail.com", "role": "admin"} -# Array of objects (plural) +# Array of objects (plural) — with pagination metadata curl http://localhost:8080/users -# → [{"id": 1, ...}, {"id": 2, ...}, ...] +# → {"data": [...], "meta": {"total": 100, "page": 1, "pageSize": 20, "totalPages": 5}} ``` **Step 4: View API Documentation** @@ -113,7 +113,78 @@ All endpoints are documented with examples and you can test them directly from y --- -## Field Constraints with JSDoc Annotations +## Pagination, Filtering & Sorting + +All list endpoints (plural routes that return an array) support pagination, filtering, and sorting via query parameters. Responses always use the envelope format: + +```json +{ + "data": [...], + "meta": { + "total": 100, + "page": 2, + "pageSize": 20, + "totalPages": 5 + } +} +``` + +The server generates a pool of 100 mock items and applies your filters/sort/pagination to that pool, so `total` and `totalPages` reflect realistic numbers. + +### Pagination + +| Param | Default | Max | Description | +|---|---|---|---| +| `page` | `1` | — | Page number (1-based) | +| `pageSize` | `20` | `100` | Items per page | + +```bash +GET /users?page=2&pageSize=50 +``` + +### Filtering + +| Convention | Example | Description | +|---|---|---| +| `field=value` | `status=active` | Exact match (case-insensitive for strings) | +| `field_like=value` | `email_like=@example.com` | Substring match (case-insensitive) | +| `field_from=date` | `createdAt_from=2024-01-01` | Date range — start (inclusive) | +| `field_to=date` | `createdAt_to=2024-12-31` | Date range — end (inclusive) | + +Multiple filters are combined with AND logic. Unknown fields are silently ignored. + +```bash +GET /users?status=active&email_like=@example.com&createdAt_from=2024-01-01 +``` + +### Sorting + +Use `sort=field:dir` with comma-separated entries for multi-field sort. Direction must be `asc` or `desc`. + +```bash +GET /users?sort=createdAt:desc,lastName:asc +``` + +Sorting by a field that does not exist in the interface returns `400`. + +### Combined Example + +```bash +GET /users?page=2&pageSize=50&status=active&email_like=@example.com&sort=createdAt:desc +``` + +### Error Responses + +Invalid query parameters return `400` with a descriptive message: + +```json +{ "error": "Invalid query parameters", "message": "\"pageSize\" must not exceed 100" } +{ "error": "Invalid sort parameter", "message": "Cannot sort by unknown field \"foo\". Allowed fields: email, id, name" } +``` + +--- + +## 🎯 Field Constraints with JSDoc Annotations Add validation constraints to your interfaces using JSDoc annotations. This ensures generated mock data follows your API rules. @@ -194,9 +265,10 @@ export interface Product { ## Available Commands - `npm run dev` - Start development server -- `npm run build` - Compile TypeScript +- `npm run build` - Compile TypeScript - `npm start` - Start production server - `npm test` - Run all tests +- `npm test -- queryProcessor.test.ts` - Test pagination/filtering/sorting - `npm test -- constraintExtractor.test.ts` - Test constraint JSDoc extraction - `npm test -- constraintValidator.test.ts` - Test constraint validation - `npm test -- constrainedGenerator.test.ts` - Test constrained data generation @@ -208,7 +280,7 @@ export interface Product { The server maps URL paths to TypeScript interfaces by converting the route to PascalCase and singularizing it. For example: - `/user` → looks for `User` interface → returns single object -- `/users` → looks for `User` interface → returns array of 3-10 objects +- `/users` → looks for `User` interface → generates 100-item pool, applies query params, returns paginated envelope Only interfaces marked with `// @endpoint` are exposed. The server uses Intermock to parse TypeScript AST and Faker to generate realistic test data. diff --git a/src/core/queryProcessor.ts b/src/core/queryProcessor.ts new file mode 100644 index 0000000..720c6a8 --- /dev/null +++ b/src/core/queryProcessor.ts @@ -0,0 +1,266 @@ +/** + * Query parameter parsing, validation, filtering, sorting and pagination + * for list (array) endpoints. + */ + +export const DEFAULT_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 20; +export const MAX_PAGE_SIZE = 100; +/** Size of the virtual "database" pool generated before filtering/pagination. */ +export const POOL_SIZE = 100; + +const RESERVED_PARAMS = new Set(['page', 'pageSize', 'sort']); + +export interface SortEntry { + field: string; + dir: 'asc' | 'desc'; +} + +export interface ParsedQueryParams { + page: number; + pageSize: number; + sort: SortEntry[]; + exactFilters: Record; + likeFilters: Record; + fromFilters: Record; + toFilters: Record; +} + +export interface PaginationMeta { + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface PaginatedResponse { + data: Record[]; + meta: PaginationMeta; +} + +export type QueryParseError = { error: string }; + +/** + * Parses and validates query parameters from an Express request. + * Returns a ParsedQueryParams on success or a QueryParseError on invalid input. + */ +export function parseQueryParams( + query: Record +): ParsedQueryParams | QueryParseError { + // page + const rawPage = query['page']; + let page = DEFAULT_PAGE; + if (rawPage !== undefined) { + const str = Array.isArray(rawPage) ? rawPage[0] ?? '' : rawPage; + const p = Number(str); + if (!Number.isInteger(p) || p < 1) { + return { error: '"page" must be a positive integer' }; + } + page = p; + } + + // pageSize + const rawPageSize = query['pageSize']; + let pageSize = DEFAULT_PAGE_SIZE; + if (rawPageSize !== undefined) { + const str = Array.isArray(rawPageSize) ? rawPageSize[0] ?? '' : rawPageSize; + const ps = Number(str); + if (!Number.isInteger(ps) || ps < 1) { + return { error: '"pageSize" must be a positive integer' }; + } + if (ps > MAX_PAGE_SIZE) { + return { error: `"pageSize" must not exceed ${MAX_PAGE_SIZE}` }; + } + pageSize = ps; + } + + // sort — comma-separated "field:dir" pairs + const rawSort = query['sort']; + const sort: SortEntry[] = []; + if (rawSort !== undefined) { + const sortStr = Array.isArray(rawSort) ? rawSort[0] ?? '' : rawSort; + const parts = sortStr.split(',').filter(Boolean); + for (const part of parts) { + const colonIdx = part.lastIndexOf(':'); + if (colonIdx === -1) { + return { + error: `Invalid sort format "${part}". Expected "field:asc" or "field:desc"`, + }; + } + const field = part.slice(0, colonIdx).trim(); + const dir = part.slice(colonIdx + 1).trim().toLowerCase(); + if (dir !== 'asc' && dir !== 'desc') { + return { + error: `Invalid sort direction "${dir}" for field "${field}". Use "asc" or "desc"`, + }; + } + if (!field) { + return { error: `Sort field name cannot be empty` }; + } + sort.push({ field, dir }); + } + } + + // filters — derived from remaining query params + const exactFilters: Record = {}; + const likeFilters: Record = {}; + const fromFilters: Record = {}; + const toFilters: Record = {}; + + for (const [key, value] of Object.entries(query)) { + if (RESERVED_PARAMS.has(key) || value === undefined) continue; + const strVal = Array.isArray(value) ? (value[0] ?? '') : value; + + if (key.endsWith('_like')) { + likeFilters[key.slice(0, -5)] = strVal; + } else if (key.endsWith('_from')) { + fromFilters[key.slice(0, -5)] = strVal; + } else if (key.endsWith('_to')) { + toFilters[key.slice(0, -3)] = strVal; + } else { + exactFilters[key] = strVal; + } + } + + return { page, pageSize, sort, exactFilters, likeFilters, fromFilters, toFilters }; +} + +/** + * Validates that all sort fields exist in the schema. + * Returns an error message string, or null if all fields are valid. + */ +export function validateSortFields( + sort: SortEntry[], + allowedFields: Set +): string | null { + for (const { field } of sort) { + if (!allowedFields.has(field)) { + return `Cannot sort by unknown field "${field}". Allowed fields: ${[...allowedFields].sort().join(', ')}`; + } + } + return null; +} + +function getFieldValue(item: Record, field: string): unknown { + return item[field]; +} + +function matchesExact( + item: Record, + field: string, + value: string +): boolean { + const v = getFieldValue(item, field); + if (v === undefined) return true; // unknown field — no constraint + if (typeof v === 'string') return v.toLowerCase() === value.toLowerCase(); + if (typeof v === 'boolean') return v === (value.toLowerCase() === 'true'); + if (typeof v === 'number') return v === Number(value); + return String(v) === value; +} + +function matchesLike( + item: Record, + field: string, + value: string +): boolean { + const v = getFieldValue(item, field); + if (v === undefined) return true; + return String(v).toLowerCase().includes(value.toLowerCase()); +} + +function matchesFrom( + item: Record, + field: string, + value: string +): boolean { + const v = getFieldValue(item, field); + if (v === undefined) return true; + const fromDate = new Date(value); + if (isNaN(fromDate.getTime())) return true; + const itemDate = new Date(String(v)); + if (isNaN(itemDate.getTime())) return true; + return itemDate >= fromDate; +} + +function matchesTo( + item: Record, + field: string, + value: string +): boolean { + const v = getFieldValue(item, field); + if (v === undefined) return true; + const toDate = new Date(value); + if (isNaN(toDate.getTime())) return true; + const itemDate = new Date(String(v)); + if (isNaN(itemDate.getTime())) return true; + return itemDate <= toDate; +} + +function applyFilters( + items: Record[], + params: ParsedQueryParams +): Record[] { + return items.filter((item) => { + for (const [field, value] of Object.entries(params.exactFilters)) { + if (!matchesExact(item, field, value)) return false; + } + for (const [field, value] of Object.entries(params.likeFilters)) { + if (!matchesLike(item, field, value)) return false; + } + for (const [field, value] of Object.entries(params.fromFilters)) { + if (!matchesFrom(item, field, value)) return false; + } + for (const [field, value] of Object.entries(params.toFilters)) { + if (!matchesTo(item, field, value)) return false; + } + return true; + }); +} + +function applySort( + items: Record[], + sort: SortEntry[] +): Record[] { + if (sort.length === 0) return items; + return [...items].sort((a, b) => { + for (const { field, dir } of sort) { + const av = a[field]; + const bv = b[field]; + if (av === bv) continue; + if (av === undefined || av === null) return dir === 'asc' ? 1 : -1; + if (bv === undefined || bv === null) return dir === 'asc' ? -1 : 1; + let cmp: number; + if (typeof av === 'string' && typeof bv === 'string') { + cmp = av.localeCompare(bv); + } else { + cmp = av < bv ? -1 : 1; + } + return dir === 'asc' ? cmp : -cmp; + } + return 0; + }); +} + +/** + * Filters, sorts, and paginates a pool of items according to the parsed query params. + * Returns a PaginatedResponse with data and meta. + */ +export function applyPagination( + pool: Record[], + params: ParsedQueryParams +): PaginatedResponse { + const filtered = applyFilters(pool, params); + const sorted = applySort(filtered, params.sort); + + const total = sorted.length; + const totalPages = total === 0 ? 1 : Math.ceil(total / params.pageSize); + // Clamp page to valid range + const page = Math.min(params.page, totalPages); + const offset = (page - 1) * params.pageSize; + const data = sorted.slice(offset, offset + params.pageSize); + + return { + data, + meta: { total, page, pageSize: params.pageSize, totalPages }, + }; +} diff --git a/src/core/router.ts b/src/core/router.ts index 645bad2..75abdd6 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -4,6 +4,12 @@ import { findTypeForUrl } from '../utils/typeMapping'; import { generateMockFromInterface, generateMockArray } from './parser'; import { schemaCache } from './cache'; import { logger } from '../utils/logger'; +import { + parseQueryParams, + validateSortFields, + applyPagination, + POOL_SIZE, +} from './queryProcessor'; /** * Dynamic route handler - Matches the URL with a type and generates the mock @@ -68,10 +74,32 @@ export function dynamicRouteHandler(config: ServerConfig) { // Generate the mock data if (mapping.isArray) { - mockData = generateMockArray( - filePath, - mapping.typeName + // Parse and validate query parameters + const parsed = parseQueryParams( + req.query as Record ); + if ('error' in parsed) { + res.status(400).json({ error: 'Invalid query parameters', message: parsed.error }); + return; + } + + // Generate a fixed pool to simulate a full dataset + const pool = generateMockArray(filePath, mapping.typeName, { + arrayLength: POOL_SIZE, + }); + + // Validate sort fields against schema keys + if (parsed.sort.length > 0 && pool.length > 0) { + const allowedFields = new Set(Object.keys(pool[0]!)); + 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 { mockData = generateMockFromInterface( filePath, diff --git a/tests/core/queryProcessor.test.ts b/tests/core/queryProcessor.test.ts new file mode 100644 index 0000000..d2e0d73 --- /dev/null +++ b/tests/core/queryProcessor.test.ts @@ -0,0 +1,299 @@ +import { + parseQueryParams, + validateSortFields, + applyPagination, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, +} from '../../src/core/queryProcessor'; + +// Helpers +function makeItems(n: number): Record[] { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + name: `Item ${i + 1}`, + status: i % 2 === 0 ? 'active' : 'inactive', + score: i + 1, + createdAt: `2024-01-${String(i + 1).padStart(2, '0')}`, + email: `user${i + 1}@example.com`, + })); +} + +describe('parseQueryParams', () => { + it('returns defaults when no query params supplied', () => { + const result = parseQueryParams({}); + expect(result).toEqual({ + page: DEFAULT_PAGE, + pageSize: DEFAULT_PAGE_SIZE, + sort: [], + exactFilters: {}, + likeFilters: {}, + fromFilters: {}, + toFilters: {}, + }); + }); + + it('parses page and pageSize', () => { + const result = parseQueryParams({ page: '2', pageSize: '50' }); + expect('error' in result).toBe(false); + if (!('error' in result)) { + expect(result.page).toBe(2); + expect(result.pageSize).toBe(50); + } + }); + + it('returns error for non-integer page', () => { + const result = parseQueryParams({ page: '1.5' }); + expect('error' in result).toBe(true); + }); + + it('returns error for page < 1', () => { + const result = parseQueryParams({ page: '0' }); + expect('error' in result).toBe(true); + }); + + it('returns error for pageSize exceeding MAX_PAGE_SIZE', () => { + const result = parseQueryParams({ pageSize: String(MAX_PAGE_SIZE + 1) }); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error).toMatch(/pageSize/); + } + }); + + it('parses a single sort entry', () => { + const result = parseQueryParams({ sort: 'name:asc' }); + expect('error' in result).toBe(false); + if (!('error' in result)) { + expect(result.sort).toEqual([{ field: 'name', dir: 'asc' }]); + } + }); + + it('parses multiple sort entries', () => { + const result = parseQueryParams({ sort: 'createdAt:desc,name:asc' }); + expect('error' in result).toBe(false); + if (!('error' in result)) { + expect(result.sort).toEqual([ + { field: 'createdAt', dir: 'desc' }, + { field: 'name', dir: 'asc' }, + ]); + } + }); + + it('returns error for invalid sort direction', () => { + const result = parseQueryParams({ sort: 'name:random' }); + expect('error' in result).toBe(true); + }); + + it('returns error for sort entry without colon', () => { + const result = parseQueryParams({ sort: 'name' }); + expect('error' in result).toBe(true); + }); + + it('parses exact filters', () => { + const result = parseQueryParams({ status: 'active' }); + expect('error' in result).toBe(false); + if (!('error' in result)) { + expect(result.exactFilters).toEqual({ status: 'active' }); + } + }); + + it('parses _like filters', () => { + const result = parseQueryParams({ email_like: '@example.com' }); + expect('error' in result).toBe(false); + if (!('error' in result)) { + expect(result.likeFilters).toEqual({ email: '@example.com' }); + } + }); + + it('parses _from and _to filters', () => { + const result = parseQueryParams({ createdAt_from: '2024-01-01', createdAt_to: '2024-12-31' }); + expect('error' in result).toBe(false); + if (!('error' in result)) { + expect(result.fromFilters).toEqual({ createdAt: '2024-01-01' }); + expect(result.toFilters).toEqual({ createdAt: '2024-12-31' }); + } + }); +}); + +describe('validateSortFields', () => { + const allowed = new Set(['id', 'name', 'score']); + + it('returns null for valid fields', () => { + expect(validateSortFields([{ field: 'name', dir: 'asc' }], allowed)).toBeNull(); + }); + + it('returns error message for unknown field', () => { + const msg = validateSortFields([{ field: 'unknown', dir: 'asc' }], allowed); + expect(msg).not.toBeNull(); + expect(msg).toMatch(/unknown/); + }); + + it('returns null for empty sort array', () => { + expect(validateSortFields([], allowed)).toBeNull(); + }); +}); + +describe('applyPagination', () => { + const baseParams = { + page: 1, + pageSize: DEFAULT_PAGE_SIZE, + sort: [], + exactFilters: {}, + likeFilters: {}, + fromFilters: {}, + toFilters: {}, + }; + + describe('pagination', () => { + it('returns first page by default', () => { + const pool = makeItems(50); + const result = applyPagination(pool, baseParams); + expect(result.meta.page).toBe(1); + expect(result.meta.pageSize).toBe(DEFAULT_PAGE_SIZE); + expect(result.meta.total).toBe(50); + expect(result.meta.totalPages).toBe(3); // ceil(50/20) + expect(result.data).toHaveLength(DEFAULT_PAGE_SIZE); + }); + + it('returns correct items for page 2', () => { + const pool = makeItems(50); + const result = applyPagination(pool, { ...baseParams, page: 2, pageSize: 10 }); + expect(result.data).toHaveLength(10); + expect(result.data[0]).toHaveProperty('id', 11); + }); + + it('clamps page to totalPages when page is too high', () => { + const pool = makeItems(5); + const result = applyPagination(pool, { ...baseParams, page: 99, pageSize: 10 }); + expect(result.meta.page).toBe(1); + expect(result.data).toHaveLength(5); + }); + + it('handles empty pool', () => { + const result = applyPagination([], baseParams); + expect(result.meta.total).toBe(0); + expect(result.meta.totalPages).toBe(1); + expect(result.data).toHaveLength(0); + }); + + it('returns partial last page', () => { + const pool = makeItems(25); + const result = applyPagination(pool, { ...baseParams, page: 3, pageSize: 10 }); + expect(result.data).toHaveLength(5); + expect(result.meta.total).toBe(25); + }); + }); + + describe('filtering', () => { + it('applies exact filter (string, case-insensitive)', () => { + const pool = makeItems(10); + const result = applyPagination(pool, { + ...baseParams, + exactFilters: { status: 'ACTIVE' }, + }); + result.data.forEach((item) => expect(item['status']).toBe('active')); + }); + + it('applies _like filter', () => { + const pool = makeItems(10); + const result = applyPagination(pool, { + ...baseParams, + likeFilters: { email: '@example.com' }, + }); + result.data.forEach((item) => + expect(String(item['email']).toLowerCase()).toContain('@example.com') + ); + }); + + it('applies _from date filter', () => { + const pool = makeItems(10); + const result = applyPagination(pool, { + ...baseParams, + fromFilters: { createdAt: '2024-01-05' }, + }); + result.data.forEach((item) => { + const d = new Date(String(item['createdAt'])); + expect(d >= new Date('2024-01-05')).toBe(true); + }); + }); + + it('applies _to date filter', () => { + const pool = makeItems(10); + const result = applyPagination(pool, { + ...baseParams, + toFilters: { createdAt: '2024-01-05' }, + }); + result.data.forEach((item) => { + const d = new Date(String(item['createdAt'])); + expect(d <= new Date('2024-01-05')).toBe(true); + }); + }); + + it('returns empty data when no items match filter', () => { + const pool = makeItems(10); + const result = applyPagination(pool, { + ...baseParams, + exactFilters: { status: 'deleted' }, + }); + expect(result.data).toHaveLength(0); + expect(result.meta.total).toBe(0); + }); + + it('ignores filter for unknown field', () => { + const pool = makeItems(5); + const result = applyPagination(pool, { + ...baseParams, + exactFilters: { nonExistentField: 'value' }, + }); + // unknown field should not remove items + expect(result.meta.total).toBe(5); + }); + }); + + describe('sorting', () => { + it('sorts ascending by numeric field', () => { + const pool = makeItems(5).reverse(); + const result = applyPagination(pool, { + ...baseParams, + sort: [{ field: 'score', dir: 'asc' }], + }); + const scores = result.data.map((item) => item['score'] as number); + expect(scores).toEqual([...scores].sort((a, b) => a - b)); + }); + + it('sorts descending by numeric field', () => { + const pool = makeItems(5); + const result = applyPagination(pool, { + ...baseParams, + sort: [{ field: 'score', dir: 'desc' }], + }); + const scores = result.data.map((item) => item['score'] as number); + expect(scores).toEqual([...scores].sort((a, b) => b - a)); + }); + + it('supports multi-field sort', () => { + const pool = [ + { id: 1, status: 'active', score: 3 }, + { id: 2, status: 'active', score: 1 }, + { id: 3, status: 'inactive', score: 5 }, + ]; + const result = applyPagination(pool, { + ...baseParams, + sort: [ + { field: 'status', dir: 'asc' }, + { field: 'score', dir: 'asc' }, + ], + }); + expect(result.data[0]).toHaveProperty('id', 2); + expect(result.data[1]).toHaveProperty('id', 1); + expect(result.data[2]).toHaveProperty('id', 3); + }); + + it('does not mutate the original pool', () => { + const pool = makeItems(5).reverse(); + const originalFirst = pool[0]; + applyPagination(pool, { ...baseParams, sort: [{ field: 'score', dir: 'asc' }] }); + expect(pool[0]).toBe(originalFirst); + }); + }); +}); From 248326b5bd87c51a4a2fc7c4ccb55ae3b954bf53 Mon Sep 17 00:00:00 2001 From: Many0nne Date: Sat, 7 Mar 2026 15:42:52 +0100 Subject: [PATCH 2/3] add options to the swagger --- src/core/router.ts | 2 +- src/core/swagger.ts | 205 +++++++++++++++++++++++++++++++++----------- 2 files changed, 156 insertions(+), 51 deletions(-) diff --git a/src/core/router.ts b/src/core/router.ts index 75abdd6..5395cd9 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -17,7 +17,7 @@ import { export function dynamicRouteHandler(config: ServerConfig) { return async (req: Request, res: Response): Promise => { try { - const url = req.url; + const url = req.path; // Search for the type corresponding to the URL const mapping = findTypeForUrl(url, config.typesDir); diff --git a/src/core/swagger.ts b/src/core/swagger.ts index e23519c..1ecae8a 100644 --- a/src/core/swagger.ts +++ b/src/core/swagger.ts @@ -3,6 +3,7 @@ import { ServerConfig } from '../types/config'; import { buildTypeMap } from '../utils/typeMapping'; import pluralize from 'pluralize'; import { toPascalCase } from '../utils/pluralize'; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './queryProcessor'; interface OpenAPISchema { type: string; @@ -12,9 +13,20 @@ interface OpenAPISchema { enum?: string[]; } +type OpenAPIParameter = + | { $ref: string } + | { + name: string; + in: string; + description: string; + required: boolean; + schema: Record; + }; + interface OpenAPIPath { summary: string; description: string; + parameters?: OpenAPIParameter[]; responses: { [statusCode: string]: { description: string; @@ -25,13 +37,6 @@ interface OpenAPIPath { }; }; }; - parameters?: Array<{ - name: string; - in: string; - description: string; - required: boolean; - schema: { type: string }; - }>; } /** @@ -129,6 +134,69 @@ function interfaceNameToPath(interfaceName: string): string { return `/${pluralize(kebab)}`; } +/** + * Builds the list of query parameters for an array endpoint: + * standard pagination/sort refs + field-specific filter parameters. + */ +function buildListParameters( + properties: Record +): OpenAPIParameter[] { + const params: OpenAPIParameter[] = [ + { $ref: '#/components/parameters/page' }, + { $ref: '#/components/parameters/pageSize' }, + { $ref: '#/components/parameters/sort' }, + ]; + + for (const [field, schema] of Object.entries(properties)) { + const isDate = schema.format === 'date-time'; + const isString = schema.type === 'string' && !isDate; + const isNumber = schema.type === 'number'; + const isBoolean = schema.type === 'boolean'; + + // Exact match — works for string, number, boolean + if (isString || isNumber || isBoolean || isDate) { + params.push({ + name: field, + in: 'query', + description: `Exact match filter on \`${field}\``, + required: false, + schema: isDate ? { type: 'string', format: 'date-time' } : { type: schema.type }, + }); + } + + // _like — substring match (non-date strings only) + if (isString) { + params.push({ + name: `${field}_like`, + in: 'query', + description: `Case-insensitive substring filter on \`${field}\``, + required: false, + schema: { type: 'string' }, + }); + } + + // _from / _to — range filters for dates + if (isDate) { + params.push({ + name: `${field}_from`, + in: 'query', + description: `Return items where \`${field}\` is on or after this date (ISO 8601)`, + required: false, + schema: { type: 'string', format: 'date-time' }, + }); + params.push({ + name: `${field}_to`, + in: 'query', + description: `Return items where \`${field}\` is on or before this date (ISO 8601)`, + required: false, + schema: { type: 'string', format: 'date-time' }, + }); + } + } + + return params; +} + /** * Generates OpenAPI specification from TypeScript interfaces */ @@ -157,41 +225,48 @@ export function generateOpenAPISpec(config: ServerConfig): Record Date: Sat, 7 Mar 2026 17:41:36 +0100 Subject: [PATCH 3/3] fix reviews --- src/core/queryProcessor.ts | 12 ++++++++++-- src/core/router.ts | 14 +++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/core/queryProcessor.ts b/src/core/queryProcessor.ts index 720c6a8..ba00604 100644 --- a/src/core/queryProcessor.ts +++ b/src/core/queryProcessor.ts @@ -153,8 +153,16 @@ function matchesExact( const v = getFieldValue(item, field); if (v === undefined) return true; // unknown field — no constraint if (typeof v === 'string') return v.toLowerCase() === value.toLowerCase(); - if (typeof v === 'boolean') return v === (value.toLowerCase() === 'true'); - if (typeof v === 'number') return v === Number(value); + if (typeof v === 'boolean') { + const normalized = value.trim().toLowerCase(); + if (normalized !== 'true' && normalized !== 'false') return true; // invalid — ignore constraint + return v === (normalized === 'true'); + } + if (typeof v === 'number') { + const numValue = Number(value); + if (Number.isNaN(numValue)) return true; // invalid — ignore constraint + return v === numValue; + } return String(v) === value; } diff --git a/src/core/router.ts b/src/core/router.ts index 5395cd9..4642645 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -74,10 +74,18 @@ export function dynamicRouteHandler(config: ServerConfig) { // 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( - req.query as Record - ); + const parsed = parseQueryParams(sanitizedQuery); if ('error' in parsed) { res.status(400).json({ error: 'Invalid query parameters', message: parsed.error }); return;