commit 1a5a7fa7e15dd66789d3819257968144163de3b9 Author: MasterAcnolo <68693319+MasterAcnolo@users.noreply.github.com> Date: Sat May 30 23:20:25 2026 +0200 feat: initial release of Quick API Express server that automatically generates REST endpoints from JSON files. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..034f136 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/data/**.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6379118 --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# Quick API + +Serve any JSON file as a REST API — filtering, sorting, and pagination included. +Drop a file in `data/`, start the server, your API is ready. + +--- + +## Why this exists + +During development, you often need to consume JSON data through an HTTP API before the real backend is ready — or simply to inspect, filter, and paginate a dataset quickly without writing any server code. + +Quick API was built out of that need. Drop a JSON file in a folder, start the server, and you immediately have a filterable, sortable, paginated REST API on top of your data. No configuration required to get started. + +It can also serve as a starting point for a more complete Express API, since the project is already structured with routes, controllers, services, and middleware. + +--- + +## Getting started + +```bash +npm install +npm run dev +``` + +The server starts on `http://localhost:3000`. + +--- + +## Adding a resource + +Place a JSON file in the `data/` directory: + +``` +data/ + users.json -> GET /users + products.json -> GET /products + orders.json -> GET /orders +``` + +Restart the server. The route is created automatically. + +The JSON file must be an **array of objects**: + +```json +[ + { "id": 1, "name": "Half-Life 3", "status": "rumored", "studio": "Valve", "genre": "fps", "released": false, "year": null }, + { "id": 2, "name": "Portal 3", "status": "rumored", "studio": "Valve", "genre": "puzzle", "released": false, "year": null }, + { "id": 3, "name": "Silksong", "status": "released", "studio": "Team Cherry", "genre": "metroidvania", "released": true, "year": 2025 }, + { "id": 4, "name": "Elder Scrolls VI", "status": "rumored", "studio": "Bethesda", "genre": "rpg", "released": false, "year": null } +] +``` + +--- + +## Available routes + +### System routes + +| Route | Description | +|-------------------------|---------------------------------------------------| +| `GET /` | Full documentation and list of loaded resources | +| `GET /health` | Health check (status, uptime) | +| `GET /resources` | Lists all available resources | +| `GET /schema/:resource` | Inferred schema (unique field names) of a resource| + +### Dynamic resource routes + +| Route | Description | +|-----------------------|---------------------------------------------------| +| `GET /:resource` | All records (query param filtering supported) | +| `GET /:resource/:id` | Single record by `id` | +| `POST /:resource` | Replace the in-memory dataset (not persisted) | + +--- + +## Query params + +### Field filtering + +``` +GET /users?role=admin # contains (string) or equals (number) +GET /users?role_ne=admin # not equal +GET /users?age_gte=18 # greater than or equal +GET /users?age_lte=65 # less than or equal +GET /users?age_gt=17 # strictly greater than +GET /users?age_lt=66 # strictly less than +``` + +Filters are combinable: + +``` +GET /users?role=admin&active=true +``` + +### Full-text search + +``` +GET /users?_q=alice # searches "alice" across all fields +``` + +### Sorting + +``` +GET /users?_sort=name +GET /users?_sort=name&_order=desc +``` + +Dot notation is supported for nested fields: + +``` +GET /users?_sort=address.city +``` + +### Pagination + +``` +GET /users?_limit=10 +GET /users?_limit=10&_page=2 +``` + +Pagination metadata is available in the response headers: + +``` +X-Total-Count: 42 +X-Page: 2 +X-Limit: 10 +``` + +### Field projection + +``` +GET /users?_fields=id,name,role +``` + +--- + +## Configuration + +All environment variables are optional: + +| Variable | Default | Description | +|---------------|----------|------------------------------------------------| +| `PORT` | `3000` | Server listening port | +| `DATA_DIR` | `./data` | Path to the JSON files directory | +| `BODY_LIMIT` | `10mb` | Maximum POST request body size | +| `LOG_ENABLED` | `true` | Enable HTTP request logging in the console | + +```bash +PORT=8080 DATA_DIR=./my-data npm run dev +``` + +All defaults are centralized in `src/config/config.js`. + +--- + +## Project structure + +``` +quick-api/ +├── data/ # JSON files -> automatic routes +│ └── users.json # Example file +├── src/ +│ ├── index.js # Entry point — bootstrap +│ ├── app.js # Express factory (middleware + routes) +│ ├── config/ +│ │ └── config.js # Centralized configuration +│ ├── controllers/ +│ │ ├── resource.controller.js # getAll / getById / replaceAll +│ │ └── system.controller.js # getDocs / getHealth / getResources / getSchema +│ ├── middleware/ +│ │ ├── requestLogger.middleware.js # Colored HTTP logs with response time +│ │ ├── resourceGuard.middleware.js # 404 if resource does not exist +│ │ ├── errorHandler.middleware.js # Global error handler +│ │ └── notFoundHandler.middleware.js # Catch-all 404 +│ ├── routes/ +│ │ ├── resource.routes.js # Dynamic routes /:resource +│ │ └── system.routes.js # Fixed routes (/, /health, /schema...) +│ ├── services/ +│ │ ├── datasetRegistry.service.js # In-memory dataset store +│ │ └── filter.service.js # Filtering, sorting, pagination engine +│ └── utils/ +│ └── response.utils.js # HTTP response helpers +└── package.json +``` + +--- + +## Adding a custom endpoint + +1. Create your controller in `src/controllers/` +2. Create your route file in `src/routes/` +3. Mount it in `src/app.js` **before** `resourceRouter` + +```js +// src/app.js +const myRouter = require('./routes/my.routes'); +app.use('/custom', myRouter); // must come before resourceRouter +``` + +--- + +## Notes + +- Data is loaded **into memory** at startup. A `POST /:resource` updates the in-memory dataset but **does not write back** to the JSON file on disk. +- Restarting the server reloads files from disk. +- The `maxLimit` value in `config.js` caps the `_limit` param to avoid oversized responses (default: 500). diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6890633 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,855 @@ +{ + "name": "quick-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "quick-api", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..28da296 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "quick-api", + "version": "1.0.0", + "description": "API Development Tool. Drop a JSON file in data/, start the server, consume the API.", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node src/index.js" + }, + "license": "MIT", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..29f0d78 --- /dev/null +++ b/src/app.js @@ -0,0 +1,38 @@ +/** + * @file app.js + * @description Express application factory. + * Separates app configuration from server startup + * to simplify testing. + */ + +'use strict'; + +const express = require('express'); +const cors = require('cors'); + +const config = require('./config/config'); +const requestLogger = require('./middleware/requestLogger.middleware'); +const errorHandler = require('./middleware/errorHandler.middleware'); +const notFoundHandler = require('./middleware/notFoundHandler.middleware'); +const resourceRouter = require('./routes/resource.routes'); +const systemRouter = require('./routes/system.routes'); + +const app = express(); + +// --- Global middleware ------------------------------------------------------- + +app.use(cors(config.cors)); +app.use(express.json({ limit: config.bodyLimit })); +app.use(requestLogger); + +// --- System routes (/, /health, /resources, /schema/:resource) -------------- +app.use('/', systemRouter); + +// --- Dynamic routes generated from JSON files -------------------------------- +app.use('/', resourceRouter); + +// --- Error handling (must be registered last) -------------------------------- +app.use(notFoundHandler); +app.use(errorHandler); + +module.exports = app; diff --git a/src/config/config.js b/src/config/config.js new file mode 100644 index 0000000..5a68a26 --- /dev/null +++ b/src/config/config.js @@ -0,0 +1,47 @@ +/** + * @file config/config.js + * @description Centralized configuration for Quick API. + * All configurable values live here. + * Environment variables take priority over defaults. + * + * Supported environment variables: + * PORT - Listening port (default: 3000) + * DATA_DIR - Path to the JSON files (default: ./data) + * BODY_LIMIT - Maximum request body size (default: 10mb) + * LOG_ENABLED - Enable HTTP request logging (default: true) + */ + +'use strict'; + +const path = require('path'); + +const config = { + /** Server listening port */ + port: parseInt(process.env.PORT || '3000', 10), + + /** Directory containing the JSON files to expose */ + dataDir: process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : path.resolve(__dirname, '../../data'), + + /** Maximum request body size for POST requests */ + bodyLimit: process.env.BODY_LIMIT || '10mb', + + /** Enable HTTP request logging */ + logEnabled: process.env.LOG_ENABLED !== 'false', + + /** CORS configuration */ + cors: { + origin: '*', + exposedHeaders: ['X-Total-Count', 'X-Page', 'X-Limit'], + }, + + /** Default pagination values */ + pagination: { + defaultPage: 1, + defaultLimit: 0, // 0 = no limit (returns all records) + maxLimit: 500, // safety cap to avoid oversized responses + }, +}; + +module.exports = config; diff --git a/src/controllers/resource.controller.js b/src/controllers/resource.controller.js new file mode 100644 index 0000000..ac6a4c3 --- /dev/null +++ b/src/controllers/resource.controller.js @@ -0,0 +1,88 @@ +/** + * @file controllers/resource.controller.js + * @description Controller for dynamic resource routes. + * Handles read and replace operations on in-memory datasets. + */ + +'use strict'; + +const datasetRegistry = require('../services/datasetRegistry.service'); +const filterService = require('../services/filter.service'); +const { setPaginationHeaders } = require('../utils/response.utils'); + +/** + * GET /:resource + * Returns all records for a resource, with optional query param filtering. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function getAll(req, res, next) { + try { + const records = datasetRegistry.get(req.params.resource); + const { data, total, page, limit } = filterService.applyFilters(records, req.query); + + setPaginationHeaders(res, { total, page, limit }); + + res.json(data); + } catch (error) { + next(error); + } +} + +/** + * GET /:resource/:id + * Returns a single record matched by its id field. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function getById(req, res, next) { + try { + const records = datasetRegistry.get(req.params.resource); + const record = records.find(item => String(item.id) === req.params.id); + + if (!record) { + return res.status(404).json({ + error: 'Not Found', + message: `Record with id "${req.params.id}" not found in "${req.params.resource}".`, + resource: req.params.resource, + }); + } + + res.json(record); + } catch (error) { + next(error); + } +} + +/** + * POST /:resource + * Replaces the entire in-memory dataset for a resource. + * Accepts an array or a single object. + * Note: changes are not persisted to disk. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function replaceAll(req, res, next) { + try { + const resourceName = req.params.resource; + const newRecords = Array.isArray(req.body) ? req.body : [req.body]; + + datasetRegistry.set(resourceName, newRecords); + + res.json({ + message: `Dataset "${resourceName}" replaced successfully.`, + resource: resourceName, + count: newRecords.length, + }); + } catch (error) { + next(error); + } +} + +module.exports = { getAll, getById, replaceAll }; diff --git a/src/controllers/system.controller.js b/src/controllers/system.controller.js new file mode 100644 index 0000000..9cfc6ea --- /dev/null +++ b/src/controllers/system.controller.js @@ -0,0 +1,143 @@ +/** + * @file controllers/system.controller.js + * @description Controller for system routes. + * Provides monitoring, documentation, and introspection endpoints. + */ + +'use strict'; + +const datasetRegistry = require('../services/datasetRegistry.service'); +const config = require('../config/config'); + +/** Server start time, used to compute uptime */ +const SERVER_START_TIME = new Date(); + +/** + * GET / + * API documentation — lists all available resources + * and describes the supported query param syntax. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +function getDocs(req, res) { + const resources = datasetRegistry.list(); + const baseUrl = `${req.protocol}://${req.get('host')}`; + + res.json({ + name: 'Quick API', + description: 'Development tool only. Do not use in production.', + version: '1.0.0', + baseUrl, + + resources: resources.map(({ name, count }) => ({ + name, + count, + endpoints: { + list: `GET ${baseUrl}/${name}`, + getById: `GET ${baseUrl}/${name}/:id`, + replace: `POST ${baseUrl}/${name}`, + schema: `GET ${baseUrl}/schema/${name}`, + }, + })), + + queryParams: { + filtering: { + '?field=value': 'Contains (string, case-insensitive) or equals (number)', + '?field_ne=value': 'Not equal', + '?field_gte=value': 'Greater than or equal', + '?field_lte=value': 'Less than or equal', + '?field_gt=value': 'Strictly greater than', + '?field_lt=value': 'Strictly less than', + '?_q=text': 'Full-text search across all fields', + }, + sorting: { + '?_sort=field': 'Sort by field (dot notation supported: meta.title)', + '?_order=desc': 'Sort direction: asc (default) | desc', + }, + pagination: { + '?_page=1': 'Page number (default: 1)', + '?_limit=10': `Results per page (default: all, max: ${config.pagination.maxLimit})`, + }, + projection: { + '?_fields=id,name': 'Return only the specified fields (comma-separated)', + }, + }, + + responseHeaders: { + 'X-Total-Count': 'Total number of results before pagination', + 'X-Page': 'Current page', + 'X-Limit': 'Applied limit', + }, + + tip: `Drop a JSON file in "${config.dataDir}" and restart the server to create a new route.`, + }); +} + +/** + * GET /health + * Health check endpoint — confirms the server is running. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +function getHealth(req, res) { + const uptimeSeconds = Math.floor((Date.now() - SERVER_START_TIME.getTime()) / 1000); + + res.json({ + status: 'ok', + uptime: `${uptimeSeconds}s`, + startedAt: SERVER_START_TIME.toISOString(), + timestamp: new Date().toISOString(), + resources: datasetRegistry.list().length, + }); +} + +/** + * GET /resources + * Lists all available resources with their metadata. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +function getResources(req, res) { + const baseUrl = `${req.protocol}://${req.get('host')}`; + const resources = datasetRegistry.list(); + + res.json({ + count: resources.length, + resources: resources.map(({ name, count }) => ({ + name, + count, + url: `${baseUrl}/${name}`, + schema: `${baseUrl}/schema/${name}`, + })), + }); +} + +/** + * GET /schema/:resource + * Returns the inferred schema (unique field names) of a resource. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +function getSchema(req, res) { + const resourceName = req.params.resource; + const schema = datasetRegistry.getSchema(resourceName); + + if (!schema) { + return res.status(404).json({ + error: 'Not Found', + message: `Resource "${resourceName}" not found.`, + }); + } + + res.json({ + resource: resourceName, + fieldCount: schema.length, + fields: schema, + }); +} + +module.exports = { getDocs, getHealth, getResources, getSchema }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..f164ac7 --- /dev/null +++ b/src/index.js @@ -0,0 +1,50 @@ +/** + * @file index.js + * @description Entry point for Quick API. + * Initializes the Express application, loads middleware, + * mounts routes, and starts the server. + * + * WARNING: Development tool only. Do not use in production. + */ + +'use strict'; + +const app = require('./app'); +const config = require('./config/config'); +const datasetRegistry = require('./services/datasetRegistry.service'); + +/** + * Bootstraps the server after loading all datasets. + */ +async function bootstrap() { + try { + await datasetRegistry.loadAll(config.dataDir); + + app.listen(config.port, () => { + console.log(''); + console.log('Quick API - Development only'); + console.log('----------------------------'); + console.log(`Server -> http://localhost:${config.port}`); + console.log(`Docs -> http://localhost:${config.port}/`); + console.log(`Health -> http://localhost:${config.port}/health`); + console.log(`Resources -> http://localhost:${config.port}/resources`); + console.log(''); + + const resources = datasetRegistry.list(); + if (resources.length === 0) { + console.log(`No JSON files found in "${config.dataDir}".`); + console.log('Drop a JSON file (e.g. data/users.json) and restart.'); + } else { + resources.forEach(({ name, count }) => + console.log(` /${name} (${count} record(s))`) + ); + } + console.log(''); + }); + } catch (error) { + console.error('Startup error:', error.message); + process.exit(1); + } +} + +bootstrap(); diff --git a/src/middleware/errorHandler.middleware.js b/src/middleware/errorHandler.middleware.js new file mode 100644 index 0000000..85a8726 --- /dev/null +++ b/src/middleware/errorHandler.middleware.js @@ -0,0 +1,32 @@ +/** + * @file middleware/errorHandler.middleware.js + * @description Global error handling middleware. + * Catches all unhandled errors and returns a clean JSON response. + * + * Must be registered LAST in app.js. + */ + +'use strict'; + +/** + * Global Express error handler (four parameters required by Express). + * + * @param {Error} error + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next - Required by Express even when unused + */ +// eslint-disable-next-line no-unused-vars +function errorHandler(error, req, res, next) { + console.error('Unhandled error:', error.message); + + const statusCode = error.statusCode || error.status || 500; + + res.status(statusCode).json({ + error: 'Internal Server Error', + message: error.message || 'An unexpected error occurred.', + ...(process.env.NODE_ENV === 'development' && { stack: error.stack }), + }); +} + +module.exports = errorHandler; diff --git a/src/middleware/notFoundHandler.middleware.js b/src/middleware/notFoundHandler.middleware.js new file mode 100644 index 0000000..2e79247 --- /dev/null +++ b/src/middleware/notFoundHandler.middleware.js @@ -0,0 +1,30 @@ +/** + * @file middleware/notFoundHandler.middleware.js + * @description Catch-all middleware for unmatched routes. + * Returns a 404 JSON response with helpful suggestions. + * + * Must be registered after all routes but before the error handler. + */ + +'use strict'; + +const datasetRegistry = require('../services/datasetRegistry.service'); + +/** + * Returns a 404 for any route not matched by previous routers. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +function notFoundHandler(req, res) { + const availableResources = datasetRegistry.list().map(resource => resource.name); + + res.status(404).json({ + error: 'Not Found', + message: `Route "${req.method} ${req.originalUrl}" does not exist.`, + availableResources, + docs: `${req.protocol}://${req.get('host')}/`, + }); +} + +module.exports = notFoundHandler; diff --git a/src/middleware/requestLogger.middleware.js b/src/middleware/requestLogger.middleware.js new file mode 100644 index 0000000..72c43b6 --- /dev/null +++ b/src/middleware/requestLogger.middleware.js @@ -0,0 +1,64 @@ +/** + * @file middleware/requestLogger.middleware.js + * @description HTTP request logging middleware. + * Logs method, URL, status code, and response time to the console. + * Can be disabled via config (LOG_ENABLED=false). + */ + +'use strict'; + +const config = require('../config/config'); + +/** ANSI color codes for console output */ +const COLOR = { + reset: '\x1b[0m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m', + gray: '\x1b[90m', +}; + +/** + * Returns the ANSI color corresponding to an HTTP status code. + * + * @param {number} statusCode - HTTP response status code + * @returns {string} ANSI escape sequence + */ +function getStatusColor(statusCode) { + if (statusCode >= 500) return COLOR.red; + if (statusCode >= 400) return COLOR.yellow; + if (statusCode >= 300) return COLOR.cyan; + return COLOR.green; +} + +/** + * Request logger middleware. + * Output format: [METHOD] /route -> STATUS (duration ms) + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function requestLogger(req, res, next) { + if (!config.logEnabled) return next(); + + const startTime = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - startTime; + const statusColor = getStatusColor(res.statusCode); + + console.log( + `${COLOR.gray}${new Date().toISOString()}${COLOR.reset}` + + ` ${COLOR.cyan}${req.method.padEnd(6)}${COLOR.reset}` + + ` ${req.originalUrl}` + + ` -> ${statusColor}${res.statusCode}${COLOR.reset}` + + ` ${COLOR.gray}(${duration}ms)${COLOR.reset}` + ); + }); + + next(); +} + +module.exports = requestLogger; diff --git a/src/middleware/resourceGuard.middleware.js b/src/middleware/resourceGuard.middleware.js new file mode 100644 index 0000000..f50a8f2 --- /dev/null +++ b/src/middleware/resourceGuard.middleware.js @@ -0,0 +1,36 @@ +/** + * @file middleware/resourceGuard.middleware.js + * @description Guard middleware for dynamic routes. + * Verifies that the requested resource exists in the registry + * before passing the request to the controller. + */ + +'use strict'; + +const datasetRegistry = require('../services/datasetRegistry.service'); + +/** + * Checks whether the requested resource exists in the registry. + * Returns a 404 with the list of available resources if not found. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function resourceGuard(req, res, next) { + const resourceName = req.params.resource; + const availableResources = datasetRegistry.list().map(resource => resource.name); + + if (!datasetRegistry.has(resourceName)) { + return res.status(404).json({ + error: 'Resource Not Found', + message: `Resource "${resourceName}" does not exist.`, + availableResources, + tip: `Drop a file named "${resourceName}.json" in the data directory and restart the server.`, + }); + } + + next(); +} + +module.exports = resourceGuard; diff --git a/src/routes/resource.routes.js b/src/routes/resource.routes.js new file mode 100644 index 0000000..3324795 --- /dev/null +++ b/src/routes/resource.routes.js @@ -0,0 +1,54 @@ +/** + * @file routes/resource.routes.js + * @description Dynamic routes for JSON resources. + * Validates resource existence via resourceGuard before + * delegating to the resource controller. + */ + +'use strict'; + +const { Router } = require('express'); +const resourceController = require('../controllers/resource.controller'); +const resourceGuard = require('../middleware/resourceGuard.middleware'); + +const router = Router(); + +/** + * @route GET /:resource + * @desc Returns all records with optional query param filtering + * @access Public + * + * @queryparam {string} [field] - Field filter (e.g. ?status=active) + * @queryparam {string} [field_ne] - Not equal + * @queryparam {string} [field_gte] - Greater than or equal + * @queryparam {string} [field_lte] - Less than or equal + * @queryparam {string} [_q] - Full-text search + * @queryparam {string} [_sort] - Sort field + * @queryparam {string} [_order] - asc | desc + * @queryparam {number} [_page] - Page number + * @queryparam {number} [_limit] - Results per page + * @queryparam {string} [_fields] - Fields to return (e.g. id,name) + */ +router.get('/:resource', resourceGuard, resourceController.getAll); + +/** + * @route GET /:resource/:id + * @desc Returns a single record by id + * @access Public + * + * @param {string} resource - Resource name + * @param {string} id - Record identifier + */ +router.get('/:resource/:id', resourceGuard, resourceController.getById); + +/** + * @route POST /:resource + * @desc Replaces the in-memory dataset (not persisted to disk) + * @access Public + * + * @param {string} resource - Resource name + * @bodyparam {Array|Object} body - New dataset (array or single object) + */ +router.post('/:resource', resourceGuard, resourceController.replaceAll); + +module.exports = router; diff --git a/src/routes/system.routes.js b/src/routes/system.routes.js new file mode 100644 index 0000000..a2a7544 --- /dev/null +++ b/src/routes/system.routes.js @@ -0,0 +1,44 @@ +/** + * @file routes/system.routes.js + * @description Fixed system routes for Quick API. + * These routes take priority over dynamic resource routes. + */ + +'use strict'; + +const { Router } = require('express'); +const systemController = require('../controllers/system.controller'); + +const router = Router(); + +/** + * @route GET / + * @desc Full API documentation with the list of available resources + * @access Public + */ +router.get('/', systemController.getDocs); + +/** + * @route GET /health + * @desc Health check — returns server status and uptime + * @access Public + */ +router.get('/health', systemController.getHealth); + +/** + * @route GET /resources + * @desc Lists all available resources with their URLs + * @access Public + */ +router.get('/resources', systemController.getResources); + +/** + * @route GET /schema/:resource + * @desc Returns the inferred schema (unique field names) of a resource + * @access Public + * + * @param {string} resource - Resource name + */ +router.get('/schema/:resource', systemController.getSchema); + +module.exports = router; diff --git a/src/services/datasetRegistry.service.js b/src/services/datasetRegistry.service.js new file mode 100644 index 0000000..080ac4f --- /dev/null +++ b/src/services/datasetRegistry.service.js @@ -0,0 +1,107 @@ +/** + * @file services/datasetRegistry.service.js + * @description Central in-memory registry for all loaded datasets. + * Reads every .json file in the data directory on startup + * and exposes basic CRUD operations against the in-memory store. + * + * Each file becomes a named resource: + * data/users.json -> resource "users" + * data/products.json -> resource "products" + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +/** @type {Map[]>} In-memory dataset store */ +const store = new Map(); + +/** + * Loads all .json files found in the target directory. + * Creates the directory if it does not exist. + * + * @param {string} dataDir - Absolute path to the data directory + * @returns {void} + */ +function loadAll(dataDir) { + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + console.log(`Data directory created: ${dataDir}`); + return; + } + + const jsonFiles = fs.readdirSync(dataDir).filter(file => file.endsWith('.json')); + + for (const file of jsonFiles) { + const resourceName = path.basename(file, '.json'); + const filePath = path.join(dataDir, file); + + try { + const rawContent = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(rawContent); + const records = Array.isArray(parsed) ? parsed : [parsed]; + + store.set(resourceName, records); + } catch (error) { + console.warn(`Could not load "${file}": ${error.message}`); + } + } +} + +/** + * Returns whether a resource exists in the registry. + * + * @param {string} resourceName + * @returns {boolean} + */ +function has(resourceName) { + return store.has(resourceName); +} + +/** + * Returns the records for a given resource. + * + * @param {string} resourceName + * @returns {Record[] | undefined} + */ +function get(resourceName) { + return store.get(resourceName); +} + +/** + * Replaces the entire dataset for a given resource. + * + * @param {string} resourceName + * @param {Record[]} records + * @returns {void} + */ +function set(resourceName, records) { + store.set(resourceName, Array.isArray(records) ? records : [records]); +} + +/** + * Returns a list of all resources with their record count. + * + * @returns {{ name: string, count: number }[]} + */ +function list() { + return [...store.entries()].map(([name, records]) => ({ + name, + count: records.length, + })); +} + +/** + * Returns the unique field names (schema) inferred from a resource. + * + * @param {string} resourceName + * @returns {string[] | null} + */ +function getSchema(resourceName) { + const records = store.get(resourceName); + if (!records) return null; + return [...new Set(records.flatMap(Object.keys))]; +} + +module.exports = { loadAll, has, get, set, list, getSchema }; diff --git a/src/services/filter.service.js b/src/services/filter.service.js new file mode 100644 index 0000000..515b783 --- /dev/null +++ b/src/services/filter.service.js @@ -0,0 +1,206 @@ +/** + * @file services/filter.service.js + * @description Filtering, sorting, and pagination engine for datasets. + * + * Query param syntax: + * ?field=value -> contains (string, case-insensitive) or equals (number) + * ?field_ne=value -> not equal + * ?field_gte=value -> greater than or equal + * ?field_lte=value -> less than or equal + * ?field_gt=value -> strictly greater than + * ?field_lt=value -> strictly less than + * ?_q=text -> full-text search across all fields + * ?_sort=field -> sort by field (dot notation supported) + * ?_order=desc -> sort direction (asc by default) + * ?_page=1&_limit=10 -> pagination + * ?_fields=id,name -> field projection (return only specified fields) + */ + +'use strict'; + +const config = require('../config/config'); + +/** Reserved query params that are not field filters */ +const RESERVED_PARAMS = new Set(['_page', '_limit', '_sort', '_order', '_fields', '_q']); + +/** Supported operator suffixes */ +const OPERATOR_SUFFIXES = ['_gte', '_lte', '_gt', '_lt', '_ne']; + +/** + * Resolves a nested value using dot notation. + * Example: getNestedValue(obj, "meta.title") + * + * @param {Record} obj - Source object + * @param {string} keyPath - Dot-separated path + * @returns {any} + */ +function getNestedValue(obj, keyPath) { + return keyPath.split('.').reduce( + (accumulator, key) => (accumulator != null ? accumulator[key] : undefined), + obj + ); +} + +/** + * Compares a field value against a filter value using a given operator. + * + * @param {any} fieldValue - Value from the record + * @param {string} filterValue - Value from the query param + * @param {string} operator - Operator: eq | ne | gte | lte | gt | lt + * @returns {boolean} + */ +function matchesFilter(fieldValue, filterValue, operator) { + const isNumericFilter = filterValue !== '' && !isNaN(Number(filterValue)); + + switch (operator) { + case 'eq': + if (fieldValue == null) return false; + if (isNumericFilter) return Number(fieldValue) === Number(filterValue); + return String(fieldValue).toLowerCase().includes(String(filterValue).toLowerCase()); + + case 'ne': + if (fieldValue == null) return true; + if (isNumericFilter) return Number(fieldValue) !== Number(filterValue); + return String(fieldValue).toLowerCase() !== String(filterValue).toLowerCase(); + + case 'gte': + if (fieldValue == null) return false; + return isNumericFilter + ? Number(fieldValue) >= Number(filterValue) + : String(fieldValue) >= filterValue; + + case 'lte': + if (fieldValue == null) return false; + return isNumericFilter + ? Number(fieldValue) <= Number(filterValue) + : String(fieldValue) <= filterValue; + + case 'gt': + if (fieldValue == null) return false; + return isNumericFilter + ? Number(fieldValue) > Number(filterValue) + : String(fieldValue) > filterValue; + + case 'lt': + if (fieldValue == null) return false; + return isNumericFilter + ? Number(fieldValue) < Number(filterValue) + : String(fieldValue) < filterValue; + + default: + return false; + } +} + +/** + * Extracts field filters from query params. + * + * @param {Record} query - Express request query params + * @returns {{ field: string, operator: string, value: string }[]} + */ +function buildFieldFilters(query) { + const filters = []; + + for (const [key, value] of Object.entries(query)) { + if (RESERVED_PARAMS.has(key)) continue; + + let field = key; + let operator = 'eq'; + + for (const suffix of OPERATOR_SUFFIXES) { + if (key.endsWith(suffix)) { + field = key.slice(0, -suffix.length); + operator = suffix.slice(1); // strip leading underscore + break; + } + } + + filters.push({ field, operator, value }); + } + + return filters; +} + +/** + * Applies filters, sorting, pagination, and field projection to a dataset. + * + * @param {Record[]} records - Full dataset + * @param {Record} query - Express request query params + * @returns {{ + * data: Record[], + * total: number, + * page: number, + * limit: number + * }} + */ +function applyFilters(records, query) { + const fieldFilters = buildFieldFilters(query); + const fullTextQuery = query._q ? String(query._q).toLowerCase() : null; + + // -- 1. Filtering ----------------------------------------------------------- + let filteredRecords = records.filter(record => { + for (const { field, operator, value } of fieldFilters) { + const fieldValue = getNestedValue(record, field); + if (!matchesFilter(fieldValue, value, operator)) return false; + } + + if (fullTextQuery) { + const recordAsText = JSON.stringify(record).toLowerCase(); + if (!recordAsText.includes(fullTextQuery)) return false; + } + + return true; + }); + + // -- 2. Sorting ------------------------------------------------------------- + if (query._sort) { + const sortDirection = query._order === 'desc' ? -1 : 1; + + filteredRecords = [...filteredRecords].sort((recordA, recordB) => { + const valueA = getNestedValue(recordA, query._sort); + const valueB = getNestedValue(recordB, query._sort); + + if (valueA == null && valueB == null) return 0; + if (valueA == null) return 1; + if (valueB == null) return -1; + + return valueA < valueB ? -sortDirection : valueA > valueB ? sortDirection : 0; + }); + } + + // -- 3. Pagination ---------------------------------------------------------- + const totalBeforePagination = filteredRecords.length; + + const page = Math.max(1, parseInt(query._page || String(config.pagination.defaultPage), 10)); + const limit = Math.min( + config.pagination.maxLimit || Infinity, + Math.max(0, parseInt(query._limit || String(config.pagination.defaultLimit), 10)) + ); + + if (limit > 0) { + const startIndex = (page - 1) * limit; + filteredRecords = filteredRecords.slice(startIndex, startIndex + limit); + } + + // -- 4. Field projection ---------------------------------------------------- + if (query._fields) { + const selectedFields = query._fields.split(',').map(field => field.trim()).filter(Boolean); + + filteredRecords = filteredRecords.map(record => { + const projectedRecord = {}; + for (const field of selectedFields) { + projectedRecord[field] = getNestedValue(record, field); + } + return projectedRecord; + }); + } + + return { + data: filteredRecords, + total: totalBeforePagination, + page, + limit, + }; +} + +module.exports = { applyFilters, getNestedValue }; diff --git a/src/utils/response.utils.js b/src/utils/response.utils.js new file mode 100644 index 0000000..d5f7641 --- /dev/null +++ b/src/utils/response.utils.js @@ -0,0 +1,23 @@ +/** + * @file utils/response.utils.js + * @description Utility helpers for building HTTP responses. + */ + +'use strict'; + +/** + * Attaches pagination metadata as response headers. + * These headers are exposed via CORS (configured in config.js). + * + * @param {import('express').Response} res + * @param {{ total: number, page: number, limit: number }} pagination + * @returns {import('express').Response} The response with headers set (chainable) + */ +function setPaginationHeaders(res, { total, page, limit }) { + return res + .set('X-Total-Count', String(total)) + .set('X-Page', String(page)) + .set('X-Limit', String(limit || total)); +} + +module.exports = { setPaginationHeaders };