Repository: typicode/json-server Branch: main Commit: 352144e0f6c8 Files: 32 Total size: 59.9 KB Directory structure: gitextract_grojiw9z/ ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── node.js.yml │ └── publish.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .oxfmtrc.json ├── LICENSE ├── README.md ├── fixtures/ │ ├── db.json │ └── db.json5 ├── package.json ├── public/ │ └── test.html ├── schema.json ├── src/ │ ├── adapters/ │ │ ├── normalized-adapter.test.ts │ │ ├── normalized-adapter.ts │ │ └── observer.ts │ ├── app.test.ts │ ├── app.ts │ ├── bin.ts │ ├── matches-where.test.ts │ ├── matches-where.ts │ ├── paginate.test.ts │ ├── paginate.ts │ ├── parse-where.test.ts │ ├── parse-where.ts │ ├── random-id.ts │ ├── service.test.ts │ ├── service.ts │ └── where-operators.ts ├── tsconfig.json └── views/ └── index.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.ts linguist-language=JavaScript ================================================ FILE: .github/FUNDING.yml ================================================ github: typicode ================================================ FILE: .github/workflows/node.js.yml ================================================ name: Node.js CI on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 with: version: 10 - uses: actions/setup-node@v6 with: node-version: "22.x" cache: "pnpm" - run: pnpm install - run: pnpm run lint - run: pnpm run typecheck - run: pnpm test ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish Package to npmjs on: release: types: [published] permissions: id-token: write # Required for OIDC contents: read jobs: build: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 with: version: 10 - uses: actions/setup-node@v6 with: node-version: "24.x" registry-url: "https://registry.npmjs.org" - run: pnpm install - run: pnpm publish --provenance --access public --no-git-checks --tag latest ================================================ FILE: .gitignore ================================================ **/*.log .DS_Store .idea lib node_modules public/output.css tmp ================================================ FILE: .husky/pre-commit ================================================ pnpm test ================================================ FILE: .oxfmtrc.json ================================================ { "semi": false, "singleQuote": true, } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2026 typicode Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # JSON-Server [![Node.js CI](https://github.com/typicode/json-server/actions/workflows/node.js.yml/badge.svg)](https://github.com/typicode/json-server/actions/workflows/node.js.yml) > [!IMPORTANT] > Viewing beta v1 documentation – usable but expect breaking changes. For stable version, see [here](https://github.com/typicode/json-server/tree/v0.17.4) > [!NOTE] > Using React ⚛️ and tired of CSS-in-JS? See [MistCSS](https://github.com/typicode/mistcss) 👀 ## Install ```shell npm install json-server ``` ## Usage Create a `db.json` or `db.json5` file ```json { "$schema": "./node_modules/json-server/schema.json", "posts": [ { "id": "1", "title": "a title", "views": 100 }, { "id": "2", "title": "another title", "views": 200 } ], "comments": [ { "id": "1", "text": "a comment about post 1", "postId": "1" }, { "id": "2", "text": "another comment about post 1", "postId": "1" } ], "profile": { "name": "typicode" } } ```
View db.json5 example ```json5 { posts: [ { id: "1", title: "a title", views: 100 }, { id: "2", title: "another title", views: 200 }, ], comments: [ { id: "1", text: "a comment about post 1", postId: "1" }, { id: "2", text: "another comment about post 1", postId: "1" }, ], profile: { name: "typicode", }, } ``` You can read more about JSON5 format [here](https://github.com/json5/json5).
Start JSON Server ```bash npx json-server db.json ``` This starts the server at `http://localhost:3000`. You should see: ``` JSON Server started on PORT :3000 http://localhost:3000 ``` Access your REST API: ```bash curl http://localhost:3000/posts/1 ``` **Response:** ```json { "id": "1", "title": "a title", "views": 100 } ``` Run `json-server --help` for a list of options ## Sponsors ✨ ### Gold | | | :--------------------------------------------------------------------------------------------------------------------------------------------------------: | | | | | | | | tower-dock-icon-light | | ### Silver | | | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | | | ### Bronze | | | | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | | | | [Become a sponsor and have your company logo here](https://github.com/users/typicode/sponsorship) ## Query Capabilities JSON Server supports advanced querying out of the box: ```http GET /posts?views:gt=100 # Filter by condition GET /posts?_sort=-views # Sort by field (descending) GET /posts?_page=1&_per_page=10 # Pagination GET /posts?_embed=comments # Include relations GET /posts?_where={"or":[...]} # Complex queries ``` See detailed documentation below for each feature. ## Routes ### Array Resources For array resources like `posts` and `comments`: ```http GET /posts GET /posts/:id POST /posts PUT /posts/:id PATCH /posts/:id DELETE /posts/:id ``` ### Object Resources For singular object resources like `profile`: ```http GET /profile PUT /profile PATCH /profile ``` ## Query params ### Conditions Use `field:operator=value`. Operators: - no operator -> `eq` (equal) - `lt` less than, `lte` less than or equal - `gt` greater than, `gte` greater than or equal - `eq` equal, `ne` not equal - `in` included in comma-separated list - `contains` string contains (case-insensitive) - `startsWith` string starts with (case-insensitive) - `endsWith` string ends with (case-insensitive) Examples: ```http GET /posts?views:gt=100 GET /posts?title:eq=Hello GET /posts?id:in=1,2,3 GET /posts?author.name:eq=typicode GET /posts?title:contains=hello GET /posts?title:startsWith=Hello GET /posts?title:endsWith=world ``` ### Sort ```http GET /posts?_sort=title GET /posts?_sort=-views GET /posts?_sort=author.name,-views ``` ### Pagination ```http GET /posts?_page=1&_per_page=25 ``` **Response:** ```json { "first": 1, "prev": null, "next": 2, "last": 4, "pages": 4, "items": 100, "data": [ { "id": "1", "title": "...", "views": 100 }, { "id": "2", "title": "...", "views": 200 } ] } ``` **Notes:** - `_per_page` defaults to `10` if not specified - Invalid `_page` or `_per_page` values are automatically normalized to valid ranges ### Embed ```http GET /posts?_embed=comments GET /comments?_embed=post ``` ### Complex filter with `_where` `_where` accepts a JSON object and overrides normal query params when valid. ```http GET /posts?_where={"or":[{"views":{"gt":100}},{"author":{"name":{"lt":"m"}}}]} ``` ## Delete dependents ```http DELETE /posts/1?_dependent=comments ``` ## Static Files JSON Server automatically serves files from the `./public` directory. To serve additional static directories: ```bash json-server db.json -s ./static json-server db.json -s ./static -s ./node_modules ``` Static files are served with standard MIME types and can include HTML, CSS, JavaScript, images, and other assets. ## Migration Notes (v0 → v1) If you are upgrading from json-server v0.x, note these behavioral changes: - **ID handling:** `id` is always a string and will be auto-generated if not provided - **Pagination:** Use `_per_page` with `_page` instead of the deprecated `_limit` parameter - **Relationships:** Use `_embed` instead of `_expand` for including related resources - **Request delays:** Use browser DevTools (Network tab > throttling) instead of the removed `--delay` CLI option > **New to json-server?** These notes are for users migrating from v0. If this is your first time using json-server, you can ignore this section. ================================================ FILE: fixtures/db.json ================================================ { "posts": [ { "id": "1", "title": "a title" }, { "id": "2", "title": "another title" } ], "comments": [ { "id": "1", "text": "a comment about post 1", "postId": "1" }, { "id": "2", "text": "another comment about post 1", "postId": "1" } ], "profile": { "name": "typicode" } } ================================================ FILE: fixtures/db.json5 ================================================ { posts: [ { id: "1", title: "a title", }, { id: "2", title: "another title", }, ], comments: [ { id: "1", text: "a comment about post 1", postId: "1", }, { id: "2", text: "another comment about post 1", postId: "1", }, ], profile: { name: "typicode", }, } ================================================ FILE: package.json ================================================ { "name": "json-server", "version": "1.0.0-beta.13", "description": "", "keywords": [ "JSON", "server", "fake", "REST", "API", "prototyping", "mock", "mocking", "test", "testing", "rest", "data", "dummy" ], "license": "MIT", "author": "typicode ", "repository": { "type": "git", "url": "git+https://github.com/typicode/json-server.git" }, "bin": { "json-server": "lib/bin.js" }, "files": [ "lib", "views", "schema.json" ], "type": "module", "scripts": { "dev": "node --watch --experimental-strip-types src/bin.ts fixtures/db.json", "build": "rm -rf lib && tsc", "prepublishOnly": "rm -rf lib && tsc", "typecheck": "tsc --noEmit", "test": "node --experimental-strip-types --test src/*.test.ts", "lint": "oxlint src", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "prepare": "husky" }, "dependencies": { "@tinyhttp/app": "^3.0.1", "@tinyhttp/cors": "^2.0.1", "@tinyhttp/logger": "^2.1.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", "dot-prop": "^10.1.0", "eta": "^4.5.0", "inflection": "^3.0.2", "json5": "^2.2.3", "lowdb": "^7.0.1", "milliparsec": "^5.1.0", "sirv": "^3.0.2", "sort-on": "^7.0.0" }, "devDependencies": { "@types/node": "^25.0.8", "concurrently": "^9.2.1", "get-port": "^7.1.0", "husky": "^9.1.7", "oxfmt": "^0.24.0", "oxlint": "^1.39.0", "tempy": "^3.1.0", "type-fest": "^5.4.0", "typescript": "^5.9.3" }, "engines": { "node": ">=22.12.0" } } ================================================ FILE: public/test.html ================================================ ================================================ FILE: schema.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": { "oneOf": [ { "type": "array" }, { "type": "object" } ] } } ================================================ FILE: src/adapters/normalized-adapter.test.ts ================================================ import assert from 'node:assert/strict' import test from 'node:test' import type { Adapter } from 'lowdb' import { DEFAULT_SCHEMA_PATH, NormalizedAdapter } from './adapters/normalized-adapter.ts' import type { RawData } from './adapters/normalized-adapter.ts' import type { Data } from './service.ts' class StubAdapter implements Adapter { #data: RawData | null constructor(data: RawData | null) { this.#data = data } async read(): Promise { return this.#data === null ? null : structuredClone(this.#data) } async write(data: RawData): Promise { this.#data = structuredClone(data) } get data(): RawData | null { return this.#data } } await test('read removes $schema and normalizes ids', async () => { const adapter = new StubAdapter({ $schema: './custom/schema.json', posts: [{ id: 1 }, { title: 'missing id' }], profile: { name: 'x' }, }) const normalized = await new NormalizedAdapter(adapter).read() assert.notEqual(normalized, null) if (normalized === null) { return } assert.equal(normalized['$schema'], undefined) assert.deepEqual(normalized['profile'], { name: 'x' }) const posts = normalized['posts'] assert.ok(Array.isArray(posts)) assert.equal(posts[0]?.['id'], '1') assert.equal(typeof posts[1]?.['id'], 'string') assert.notEqual(posts[1]?.['id'], '') }) await test('write always overwrites $schema', async () => { const adapter = new StubAdapter(null) const normalizedAdapter = new NormalizedAdapter(adapter) await normalizedAdapter.write({ posts: [{ id: '1' }] } satisfies Data) const data = adapter.data assert.notEqual(data, null) assert.equal(data?.['$schema'], DEFAULT_SCHEMA_PATH) }) ================================================ FILE: src/adapters/normalized-adapter.ts ================================================ import type { Adapter } from 'lowdb' import { randomId } from '../random-id.ts' import type { Data, Item } from '../service.ts' export const DEFAULT_SCHEMA_PATH = './node_modules/json-server/schema.json' export type RawData = Record & { $schema?: string } export class NormalizedAdapter implements Adapter { #adapter: Adapter constructor(adapter: Adapter) { this.#adapter = adapter } async read(): Promise { const data = await this.#adapter.read() if (data === null) { return null } delete data['$schema'] for (const value of Object.values(data)) { if (Array.isArray(value)) { for (const item of value) { if (typeof item['id'] === 'number') { item['id'] = item['id'].toString() } if (item['id'] === undefined) { item['id'] = randomId() } } } } return data as Data } async write(data: Data): Promise { await this.#adapter.write({ ...data, $schema: DEFAULT_SCHEMA_PATH }) } } ================================================ FILE: src/adapters/observer.ts ================================================ import type { Adapter } from 'lowdb' // Lowdb adapter to observe read/write events export class Observer { #adapter: Adapter onReadStart = function () { return } onReadEnd: (data: T | null) => void = function () { return } onWriteStart = function () { return } onWriteEnd = function () { return } constructor(adapter: Adapter) { this.#adapter = adapter } async read() { this.onReadStart() const data = await this.#adapter.read() this.onReadEnd(data) return data } async write(arg: T) { this.onWriteStart() await this.#adapter.write(arg) this.onWriteEnd() } } ================================================ FILE: src/app.test.ts ================================================ import assert from 'node:assert/strict' import { writeFileSync } from 'node:fs' import { join } from 'node:path' import test from 'node:test' import getPort from 'get-port' import { Low, Memory } from 'lowdb' import { temporaryDirectory } from 'tempy' import { createApp } from './app.ts' import type { Data } from './service.ts' type Test = { method: HTTPMethods url: string statusCode: number } type HTTPMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' const port = await getPort() // Create custom static dir with an html file const tmpDir = temporaryDirectory() const file = 'file.html' writeFileSync(join(tmpDir, file), 'utf-8') // Create app const db = new Low(new Memory(), {}) db.data = { posts: [{ id: '1', title: 'foo' }], comments: [{ id: '1', postId: '1' }], object: { f1: 'foo' }, } const app = createApp(db, { static: [tmpDir] }) await new Promise((resolve, reject) => { try { const server = app.listen(port, () => resolve()) test.after(() => server.close()) } catch (err) { reject(err) } }) await test('createApp', async (t) => { // URLs const POSTS = '/posts' const POSTS_WITH_COMMENTS = '/posts?_embed=comments' const POST_1 = '/posts/1' const POST_NOT_FOUND = '/posts/-1' const POST_WITH_COMMENTS = '/posts/1?_embed=comments' const COMMENTS = '/comments' const POST_COMMENTS = '/comments?postId=1' const NOT_FOUND = '/not-found' const OBJECT = '/object' const OBJECT_1 = '/object/1' const arr: Test[] = [ // Static { method: 'GET', url: '/', statusCode: 200 }, { method: 'GET', url: '/test.html', statusCode: 200 }, { method: 'GET', url: `/${file}`, statusCode: 200 }, // CORS { method: 'OPTIONS', url: POSTS, statusCode: 204 }, // API { method: 'GET', url: POSTS, statusCode: 200 }, { method: 'GET', url: POSTS_WITH_COMMENTS, statusCode: 200 }, { method: 'GET', url: POST_1, statusCode: 200 }, { method: 'GET', url: POST_NOT_FOUND, statusCode: 404 }, { method: 'GET', url: POST_WITH_COMMENTS, statusCode: 200 }, { method: 'GET', url: COMMENTS, statusCode: 200 }, { method: 'GET', url: POST_COMMENTS, statusCode: 200 }, { method: 'GET', url: OBJECT, statusCode: 200 }, { method: 'GET', url: OBJECT_1, statusCode: 404 }, { method: 'GET', url: NOT_FOUND, statusCode: 404 }, { method: 'POST', url: POSTS, statusCode: 201 }, { method: 'POST', url: POST_1, statusCode: 404 }, { method: 'POST', url: POST_NOT_FOUND, statusCode: 404 }, { method: 'POST', url: OBJECT, statusCode: 404 }, { method: 'POST', url: OBJECT_1, statusCode: 404 }, { method: 'POST', url: NOT_FOUND, statusCode: 404 }, { method: 'PUT', url: POSTS, statusCode: 404 }, { method: 'PUT', url: POST_1, statusCode: 200 }, { method: 'PUT', url: OBJECT, statusCode: 200 }, { method: 'PUT', url: OBJECT_1, statusCode: 404 }, { method: 'PUT', url: POST_NOT_FOUND, statusCode: 404 }, { method: 'PUT', url: NOT_FOUND, statusCode: 404 }, { method: 'PATCH', url: POSTS, statusCode: 404 }, { method: 'PATCH', url: POST_1, statusCode: 200 }, { method: 'PATCH', url: OBJECT, statusCode: 200 }, { method: 'PATCH', url: OBJECT_1, statusCode: 404 }, { method: 'PATCH', url: POST_NOT_FOUND, statusCode: 404 }, { method: 'PATCH', url: NOT_FOUND, statusCode: 404 }, { method: 'DELETE', url: POSTS, statusCode: 404 }, { method: 'DELETE', url: POST_1, statusCode: 200 }, { method: 'DELETE', url: OBJECT, statusCode: 404 }, { method: 'DELETE', url: OBJECT_1, statusCode: 404 }, { method: 'DELETE', url: POST_NOT_FOUND, statusCode: 404 }, { method: 'DELETE', url: NOT_FOUND, statusCode: 404 }, ] for (const tc of arr) { await t.test(`${tc.method} ${tc.url}`, async () => { const response = await fetch(`http://localhost:${port}${tc.url}`, { method: tc.method, }) assert.equal( response.status, tc.statusCode, `${response.status} !== ${tc.statusCode} ${tc.method} ${tc.url} failed`, ) }) } await t.test('GET /posts?_where=... uses JSON query', async () => { // Reset data since previous tests may have modified it db.data = { posts: [{ id: '1', title: 'foo' }], comments: [{ id: '1', postId: '1' }], object: { f1: 'foo' }, } const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } })) const response = await fetch(`http://localhost:${port}/posts?_where=${where}`) assert.equal(response.status, 200) const data = await response.json() assert.deepEqual(data, [{ id: '1', title: 'foo' }]) }) await t.test('GET /posts?_where=... overrides query params', async () => { const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } })) const response = await fetch( `http://localhost:${port}/posts?title:eq=bar&_where=${where}`, ) assert.equal(response.status, 200) const data = await response.json() assert.deepEqual(data, [{ id: '1', title: 'foo' }]) }) await t.test('POST /posts with array body returns 400', async () => { const response = await fetch(`http://localhost:${port}/posts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([{ title: 'foo' }]), }) assert.equal(response.status, 400) const data = await response.json() assert.deepEqual(data, { error: 'Body must be a JSON object' }) }) await t.test('PATCH /posts/1 with string body returns 400', async () => { const response = await fetch(`http://localhost:${port}/posts/1`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify('hello'), }) assert.equal(response.status, 400) const data = await response.json() assert.deepEqual(data, { error: 'Body must be a JSON object' }) }) await t.test('PUT /posts/1 with null body returns 400', async () => { const response = await fetch(`http://localhost:${port}/posts/1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(null), }) assert.equal(response.status, 400) const data = await response.json() assert.deepEqual(data, { error: 'Body must be a JSON object' }) }) }) ================================================ FILE: src/app.ts ================================================ import { dirname, isAbsolute, join } from 'node:path' import { fileURLToPath } from 'node:url' import { App } from '@tinyhttp/app' import { cors } from '@tinyhttp/cors' import { Eta } from 'eta' import { Low } from 'lowdb' import { json } from 'milliparsec' import sirv from 'sirv' import { parseWhere } from './parse-where.ts' import type { Data } from './service.ts' import { isItem, Service } from './service.ts' const __dirname = dirname(fileURLToPath(import.meta.url)) const isProduction = process.env['NODE_ENV'] === 'production' export type AppOptions = { logger?: boolean static?: string[] } const eta = new Eta({ views: join(__dirname, '../views'), cache: isProduction, }) const RESERVED_QUERY_KEYS = new Set(['_sort', '_page', '_per_page', '_embed', '_where']) function parseListParams(req: any) { const queryString = req.url.split('?')[1] ?? '' const params = new URLSearchParams(queryString) const filterParams = new URLSearchParams() for (const [key, value] of params.entries()) { if (!RESERVED_QUERY_KEYS.has(key)) { filterParams.append(key, value) } } let where = parseWhere(filterParams.toString()) const rawWhere = params.get('_where') if (typeof rawWhere === 'string') { try { const parsed = JSON.parse(rawWhere) if (typeof parsed === 'object' && parsed !== null) { where = parsed } } catch { // Ignore invalid JSON and fallback to parsed query params } } const pageRaw = params.get('_page') const perPageRaw = params.get('_per_page') const page = pageRaw === null ? undefined : Number.parseInt(pageRaw, 10) const perPage = perPageRaw === null ? undefined : Number.parseInt(perPageRaw, 10) return { where, sort: params.get('_sort') ?? undefined, page: Number.isNaN(page) ? undefined : page, perPage: Number.isNaN(perPage) ? undefined : perPage, embed: req.query['_embed'], } } function withBody(action: (name: string, body: Record) => Promise) { return async (req: any, res: any, next: any) => { const { name = '' } = req.params if (!isItem(req.body)) { res.status(400).json({ error: 'Body must be a JSON object' }) return } res.locals['data'] = await action(name, req.body) next?.() } } function withIdAndBody( action: (name: string, id: string, body: Record) => Promise, ) { return async (req: any, res: any, next: any) => { const { name = '', id = '' } = req.params if (!isItem(req.body)) { res.status(400).json({ error: 'Body must be a JSON object' }) return } res.locals['data'] = await action(name, id, req.body) next?.() } } export function createApp(db: Low, options: AppOptions = {}) { // Create service const service = new Service(db) // Create app const app = new App() // Static files app.use(sirv('public', { dev: !isProduction })) options.static ?.map((path) => (isAbsolute(path) ? path : join(process.cwd(), path))) .forEach((dir) => app.use(sirv(dir, { dev: !isProduction }))) // CORS app .use((req, res, next) => { return cors({ allowedHeaders: req.headers['access-control-request-headers'] ?.split(',') .map((h) => h.trim()), })(req, res, next) }) .options('*', cors()) // Body parser app.use(json()) app.get('/', (_req, res) => res.send(eta.render('index.html', { data: db.data }))) app.get('/:name', (req, res, next) => { const { name = '' } = req.params const { where, sort, page, perPage, embed } = parseListParams(req) res.locals['data'] = service.find(name, { where, sort, page, perPage, embed, }) next?.() }) app.get('/:name/:id', (req, res, next) => { const { name = '', id = '' } = req.params res.locals['data'] = service.findById(name, id, req.query) next?.() }) app.post('/:name', withBody(service.create.bind(service))) app.put('/:name', withBody(service.update.bind(service))) app.put('/:name/:id', withIdAndBody(service.updateById.bind(service))) app.patch('/:name', withBody(service.patch.bind(service))) app.patch('/:name/:id', withIdAndBody(service.patchById.bind(service))) app.delete('/:name/:id', async (req, res, next) => { const { name = '', id = '' } = req.params res.locals['data'] = await service.destroyById(name, id, req.query['_dependent']) next?.() }) app.use('/:name', (req, res) => { const { data } = res.locals if (data === undefined) { res.sendStatus(404) } else { if (req.method === 'POST') res.status(201) res.json(data) } }) return app } ================================================ FILE: src/bin.ts ================================================ #!/usr/bin/env node import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { extname } from "node:path"; import { parseArgs } from "node:util"; import chalk from "chalk"; import { watch } from "chokidar"; import JSON5 from "json5"; import { Low } from "lowdb"; import type { Adapter } from "lowdb"; import { DataFile, JSONFile } from "lowdb/node"; import type { PackageJson } from "type-fest"; import { fileURLToPath } from "node:url"; import { NormalizedAdapter } from "./adapters/normalized-adapter.ts"; import type { RawData } from "./adapters/normalized-adapter.ts"; import { Observer } from "./adapters/observer.ts"; import { createApp } from "./app.ts"; import type { Data } from "./service.ts"; function help() { console.log(`Usage: json-server [options] Options: -p, --port Port (default: 3000) -h, --host Host (default: localhost) -s, --static Static files directory (multiple allowed) --help Show this message --version Show version number `); } // Parse args function args(): { file: string; port: number; host: string; static: string[]; } { try { const { values, positionals } = parseArgs({ options: { port: { type: "string", short: "p", default: process.env["PORT"] ?? "3000", }, host: { type: "string", short: "h", default: process.env["HOST"] ?? "localhost", }, static: { type: "string", short: "s", multiple: true, default: [], }, help: { type: "boolean", }, version: { type: "boolean", }, // Deprecated watch: { type: "boolean", short: "w", }, }, allowPositionals: true, }); // --version if (values.version) { const pkg = JSON.parse( readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf-8"), ) as PackageJson; console.log(pkg.version); process.exit(); } // Handle --watch if (values.watch) { console.log( chalk.yellow( "--watch/-w can be omitted, JSON Server 1+ watches for file changes by default", ), ); } if (values.help || positionals.length === 0) { help(); process.exit(); } // App args and options return { file: positionals[0] ?? "", port: parseInt(values.port as string), host: values.host as string, static: values.static as string[], }; } catch (e) { if ((e as NodeJS.ErrnoException).code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") { console.log(chalk.red((e as NodeJS.ErrnoException).message.split(".")[0])); help(); process.exit(1); } else { throw e; } } } const { file, port, host, static: staticArr } = args(); if (!existsSync(file)) { console.log(chalk.red(`File ${file} not found`)); process.exit(1); } // Handle empty string JSON file if (readFileSync(file, "utf-8").trim() === "") { writeFileSync(file, "{}"); } // Set up database let adapter: Adapter; if (extname(file) === ".json5") { adapter = new DataFile(file, { parse: JSON5.parse, stringify: JSON5.stringify, }); } else { adapter = new JSONFile(file); } const observer = new Observer(new NormalizedAdapter(adapter)); const db = new Low(observer, {}); await db.read(); // Create app const app = createApp(db, { logger: false, static: staticArr }); function logRoutes(data: Data) { console.log(chalk.bold("Endpoints:")); if (Object.keys(data).length === 0) { console.log(chalk.gray(`No endpoints found, try adding some data to ${file}`)); return; } console.log( Object.keys(data) .map((key) => `${chalk.gray(`http://${host}:${port}/`)}${chalk.blue(key)}`) .join("\n"), ); } const kaomojis = ["♡⸜(˶˃ ᵕ ˂˶)⸝♡", "♡( ◡‿◡ )", "( ˶ˆ ᗜ ˆ˵ )", "(˶ᵔ ᵕ ᵔ˶)"]; function randomItem(items: string[]): string { const index = Math.floor(Math.random() * items.length); return items.at(index) ?? ""; } app.listen(port, () => { console.log( [ chalk.bold(`JSON Server started on PORT :${port}`), chalk.gray("Press CTRL-C to stop"), chalk.gray(`Watching ${file}...`), "", chalk.magenta(randomItem(kaomojis)), "", chalk.bold("Index:"), chalk.gray(`http://localhost:${port}/`), "", chalk.bold("Static files:"), chalk.gray("Serving ./public directory if it exists"), "", ].join("\n"), ); logRoutes(db.data); }); // Watch file for changes if (process.env["NODE_ENV"] !== "production") { let writing = false; // true if the file is being written to by the app let hadReadError = false; let prevEndpoints = ""; observer.onWriteStart = () => { writing = true; }; observer.onWriteEnd = () => { writing = false; }; observer.onReadStart = () => { prevEndpoints = JSON.stringify(Object.keys(db.data).sort()); }; observer.onReadEnd = (data) => { if (data === null) { return; } const nextEndpoints = JSON.stringify(Object.keys(data).sort()); if (hadReadError || prevEndpoints !== nextEndpoints) { console.log(); logRoutes(data); } hadReadError = false; }; watch(file).on("change", () => { // Do no reload if the file is being written to by the app if (!writing) { db.read().catch((e) => { if (e instanceof SyntaxError) { hadReadError = true; return console.log(chalk.red(["", `Error parsing ${file}`, e.message].join("\n"))); } console.log(e); }); } }); } ================================================ FILE: src/matches-where.test.ts ================================================ import assert from 'node:assert/strict' import test from 'node:test' import type { JsonObject } from 'type-fest' import { matchesWhere } from './matches-where.ts' await test('matchesWhere', async (t) => { const obj: JsonObject = { a: 10, b: 20, c: 'x', nested: { a: 10, b: 20 } } const cases: [JsonObject, boolean][] = [ [{ a: { eq: 10 } }, true], [{ a: { eq: 11 } }, false], [{ c: { ne: 'y' } }, true], [{ c: { ne: 'x' } }, false], [{ a: { lt: 11 } }, true], [{ a: { lt: 10 } }, false], [{ a: { lte: 10 } }, true], [{ a: { lte: 9 } }, false], [{ b: { gt: 19 } }, true], [{ b: { gt: 20 } }, false], [{ b: { gte: 20 } }, true], [{ b: { gte: 21 } }, false], [{ a: { gt: 0 }, b: { lt: 30 } }, true], [{ a: { gt: 10 }, b: { lt: 30 } }, false], [{ or: [{ a: { lt: 0 } }, { b: { gt: 19 } }] }, true], [{ or: [{ a: { lt: 0 } }, { b: { gt: 20 } }] }, false], [{ nested: { a: { eq: 10 } } }, true], [{ nested: { b: { lt: 20 } } }, false], [{ a: { in: [10, 11] } }, true], [{ a: { in: [1, 2] } }, false], [{ c: { in: ['x', 'y'] } }, true], [{ c: { in: ['y', 'z'] } }, false], [{ a: { in: [10, 11], gt: 9 } }, true], [{ a: { in: [10, 11], gt: 10 } }, false], [{ a: { foo: 10 } }, true], [{ a: { foo: 10, eq: 10 } }, true], [{ missing: { foo: 1 } }, true], // contains [{ c: { contains: 'x' } }, true], [{ c: { contains: 'X' } }, true], [{ c: { contains: 'z' } }, false], [{ a: { contains: '1' } }, false], [{ c: { contains: 1 } }, false], // startsWith [{ c: { startsWith: 'x' } }, true], [{ c: { startsWith: 'X' } }, true], [{ c: { startsWith: 'z' } }, false], [{ a: { startsWith: '1' } }, false], [{ c: { startsWith: 1 } }, false], // endsWith [{ c: { endsWith: 'x' } }, true], [{ c: { endsWith: 'X' } }, true], [{ c: { endsWith: 'z' } }, false], [{ a: { endsWith: '1' } }, false], [{ c: { endsWith: 1 } }, false], ] for (const [query, expected] of cases) { await t.test(JSON.stringify(query), () => { assert.equal(matchesWhere(obj, query), expected) }) } }) ================================================ FILE: src/matches-where.ts ================================================ import type { JsonObject } from 'type-fest' import { WHERE_OPERATORS, type WhereOperator } from './where-operators.ts' type OperatorObject = Partial> function isJSONObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value) } function getKnownOperators(value: unknown): WhereOperator[] { if (!isJSONObject(value)) return [] const ops: WhereOperator[] = [] for (const op of WHERE_OPERATORS) { if (op in value) { ops.push(op) } } return ops } export function matchesWhere(obj: JsonObject, where: JsonObject): boolean { for (const [key, value] of Object.entries(where)) { if (key === 'or') { if (!Array.isArray(value) || value.length === 0) return false let matched = false for (const subWhere of value) { if (isJSONObject(subWhere) && matchesWhere(obj, subWhere)) { matched = true break } } if (!matched) return false continue } const field = (obj as Record)[key] if (isJSONObject(value)) { const knownOps = getKnownOperators(value) if (knownOps.length > 0) { if (field === undefined) return false const op = value as OperatorObject if (knownOps.includes('lt') && !((field as any) < (op.lt as any))) return false if (knownOps.includes('lte') && !((field as any) <= (op.lte as any))) return false if (knownOps.includes('gt') && !((field as any) > (op.gt as any))) return false if (knownOps.includes('gte') && !((field as any) >= (op.gte as any))) return false if (knownOps.includes('eq') && !((field as any) === (op.eq as any))) return false if (knownOps.includes('ne') && !((field as any) !== (op.ne as any))) return false if (knownOps.includes('in')) { const inValues = Array.isArray(op.in) ? op.in : [op.in] if (!inValues.some((v) => (field as any) === (v as any))) return false } if (knownOps.includes('contains')) { if (typeof field !== 'string') return false if (!field.toLowerCase().includes(String(op.contains).toLowerCase())) return false } if (knownOps.includes('startsWith')) { if (typeof field !== 'string') return false if (!field.toLowerCase().startsWith(String(op.startsWith).toLowerCase())) return false } if (knownOps.includes('endsWith')) { if (typeof field !== 'string') return false if (!field.toLowerCase().endsWith(String(op.endsWith).toLowerCase())) return false } continue } if (isJSONObject(field)) { if (!matchesWhere(field, value)) return false } continue } if (field === undefined) return false return false } return true } ================================================ FILE: src/paginate.test.ts ================================================ import assert from 'node:assert/strict' import test from 'node:test' import { paginate } from './paginate.ts' await test('paginate', async (t) => { // Pagination: page boundaries and clamping behavior. const cases = [ { name: 'page=1 perPage=2 items=5 -> [1,2]', items: [1, 2, 3, 4, 5], page: 1, perPage: 2, expected: { first: 1, prev: null, next: 2, last: 3, pages: 3, items: 5, data: [1, 2], }, }, { name: 'page=2 perPage=2 items=5 -> [3,4]', items: [1, 2, 3, 4, 5], page: 2, perPage: 2, expected: { first: 1, prev: 1, next: 3, last: 3, pages: 3, items: 5, data: [3, 4], }, }, { name: 'page=9 perPage=2 items=5 -> clamp to last', items: [1, 2, 3, 4, 5], page: 9, perPage: 2, expected: { first: 1, prev: 2, next: null, last: 3, pages: 3, items: 5, data: [5], }, }, { name: 'page=0 perPage=2 items=3 -> clamp to first', items: [1, 2, 3], page: 0, perPage: 2, expected: { first: 1, prev: null, next: 2, last: 2, pages: 2, items: 3, data: [1, 2], }, }, { name: 'items=[] page=1 perPage=2 -> stable empty pagination', items: [], page: 1, perPage: 2, expected: { first: 1, prev: null, next: null, last: 1, pages: 1, items: 0, data: [], }, }, { name: 'perPage=0 -> clamp perPage to 1', items: [1, 2, 3], page: 1, perPage: 0, expected: { first: 1, prev: null, next: 2, last: 3, pages: 3, items: 3, data: [1], }, }, ] for (const tc of cases) { await t.test(tc.name, () => { const res = paginate(tc.items, tc.page, tc.perPage) assert.deepEqual(res, tc.expected) }) } }) ================================================ FILE: src/paginate.ts ================================================ export type PaginationResult = { first: number prev: number | null next: number | null last: number pages: number items: number data: T[] } export function paginate(items: T[], page: number, perPage: number): PaginationResult { const totalItems = items.length const safePerPage = Number.isFinite(perPage) && perPage > 0 ? Math.floor(perPage) : 1 const pages = Math.max(1, Math.ceil(totalItems / safePerPage)) // Ensure page is within the valid range const safePage = Number.isFinite(page) ? Math.floor(page) : 1 const currentPage = Math.max(1, Math.min(safePage, pages)) const first = 1 const prev = currentPage > 1 ? currentPage - 1 : null const next = currentPage < pages ? currentPage + 1 : null const last = pages const start = (currentPage - 1) * safePerPage const end = start + safePerPage const data = items.slice(start, end) return { first, prev, next, last, pages, items: totalItems, data, } } ================================================ FILE: src/parse-where.test.ts ================================================ import assert from 'node:assert/strict' import test from 'node:test' import { parseWhere } from './parse-where.ts' await test('parseWhere', async (t) => { const cases: [string, Record][] = [ [ 'views:gt=100&title:eq=a', { views: { gt: 100 }, title: { eq: 'a' }, }, ], [ 'title=hello', { title: { eq: 'hello' }, }, ], [ 'author.name:lt=c&author.id:ne=2', { author: { name: { lt: 'c' }, id: { ne: 2 }, }, }, ], [ 'views:gt=100&views:lt=300', { views: { gt: 100, lt: 300 }, }, ], [ 'name:eq=Alice', { name: { eq: 'Alice' }, }, ], [ 'views:gt=100&published:eq=true&ratio:lt=0.5&deleted:eq=null', { views: { gt: 100 }, published: { eq: true }, ratio: { lt: 0.5 }, deleted: { eq: null }, }, ], [ 'views:foo=100&title:eq=a', { title: { eq: 'a' }, }, ], ['views:foo=100', {}], [ 'views_gt=100&title_eq=a', { views: { gt: 100 }, title: { eq: 'a' }, }, ], [ 'first_name_eq=Alice&author.first_name_ne=Bob', { first_name: { eq: 'Alice' }, author: { first_name: { ne: 'Bob' }, }, }, ], [ 'first_name=Alice', { first_name: { eq: 'Alice' }, }, ], [ 'views_gt=100&views:lt=300', { views: { gt: 100, lt: 300 }, }, ], [ 'id:in=1,3', { id: { in: [1, 3] }, }, ], [ 'title_in=hello,world', { title: { in: ['hello', 'world'] }, }, ], [ 'title:contains=ello', { title: { contains: 'ello' }, }, ], [ 'title:startsWith=hel', { title: { startsWith: 'hel' }, }, ], [ 'title:endsWith=rld', { title: { endsWith: 'rld' }, }, ], ] for (const [query, expected] of cases) { await t.test(query, () => { assert.deepEqual(parseWhere(query), expected) }) } }) ================================================ FILE: src/parse-where.ts ================================================ import { setProperty } from 'dot-prop' import type { JsonObject } from 'type-fest' import { isWhereOperator, type WhereOperator } from './where-operators.ts' function splitKey(key: string): { path: string; op: WhereOperator | null } { const colonIdx = key.lastIndexOf(':') if (colonIdx !== -1) { const path = key.slice(0, colonIdx) const op = key.slice(colonIdx + 1) if (!op) { return { path: key, op: 'eq' } } return isWhereOperator(op) ? { path, op } : { path, op: null } } // Compatibility with v0.17 operator style (e.g. _lt, _gt) const underscoreMatch = key.match(/^(.*)_([a-z]+)$/) if (underscoreMatch) { const path = underscoreMatch[1] const op = underscoreMatch[2] if (path && isWhereOperator(op)) { return { path, op } } } return { path: key, op: 'eq' } } function setPathOp(root: JsonObject, path: string, op: WhereOperator, value: string): void { const fullPath = `${path}.${op}` if (op === 'in') { setProperty( root, fullPath, value.split(',').map((part) => coerceValue(part.trim())), ) return } setProperty(root, fullPath, coerceValue(value)) } function coerceValue(value: string): string | number | boolean | null { if (value === 'true') return true if (value === 'false') return false if (value === 'null') return null if (value.trim() === '') return value const num = Number(value) if (Number.isFinite(num)) return num return value } export function parseWhere(query: string): JsonObject { const out: JsonObject = {} const params = new URLSearchParams(query) for (const [rawKey, rawValue] of params.entries()) { const { path, op } = splitKey(rawKey) if (op === null) continue setPathOp(out, path, op, rawValue) } return out } ================================================ FILE: src/random-id.ts ================================================ import { randomBytes } from 'node:crypto' export function randomId(): string { return randomBytes(2).toString('hex') } ================================================ FILE: src/service.test.ts ================================================ import assert from 'node:assert/strict' import test, { beforeEach } from 'node:test' import { Low, Memory } from 'lowdb' import type { JsonObject } from 'type-fest' import type { Data } from './service.ts' import { Service } from './service.ts' const defaultData = { posts: [], comments: [], object: {} } const adapter = new Memory() const db = new Low(adapter, defaultData) const service = new Service(db) const POSTS = 'posts' const COMMENTS = 'comments' const OBJECT = 'object' const UNKNOWN_RESOURCE = 'xxx' const UNKNOWN_ID = 'xxx' const post1 = { id: '1', title: 'a', views: 100, published: true, author: { name: 'foo' }, tags: ['foo', 'bar'], } const post2 = { id: '2', title: 'b', views: 200, published: false, author: { name: 'bar' }, tags: ['bar'], } const post3 = { id: '3', title: 'c', views: 300, published: false, author: { name: 'baz' }, tags: ['foo'], } const comment1 = { id: '1', title: 'a', postId: '1' } const obj = { f1: 'foo', } beforeEach(() => { db.data = structuredClone({ posts: [post1, post2, post3], comments: [comment1], object: obj, }) }) await test('findById', () => { const cases: [[string, string, { _embed?: string[] | string }], unknown][] = [ [[POSTS, '1', {}], db.data?.[POSTS]?.[0]], [[POSTS, UNKNOWN_ID, {}], undefined], [[POSTS, '1', { _embed: ['comments'] }], { ...post1, comments: [comment1] }], [[COMMENTS, '1', { _embed: ['post'] }], { ...comment1, post: post1 }], [[UNKNOWN_RESOURCE, '1', {}], undefined], ] for (const [[name, id, query], expected] of cases) { assert.deepEqual(service.findById(name, id, query), expected) } }) await test('find', async (t) => { const whereFromPayload = JSON.parse('{"author":{"name":{"eq":"bar"}}}') as JsonObject const cases: [{ where: JsonObject; sort?: string; page?: number; perPage?: number }, unknown][] = [ [{ where: { title: { eq: 'b' } } }, [post2]], [{ where: whereFromPayload }, [post2]], [{ where: {}, sort: '-views' }, [post3, post2, post1]], [ { where: {}, page: 2, perPage: 2 }, { first: 1, prev: 1, next: null, last: 2, pages: 2, items: 3, data: [post3], }, ], ] for (const [opts, expected] of cases) { await t.test(JSON.stringify(opts), () => { assert.deepEqual(service.find(POSTS, opts), expected) }) } }) await test('create', async () => { const post = { title: 'new post' } const res = await service.create(POSTS, post) assert.equal(res?.['title'], post.title) assert.equal(typeof res?.['id'], 'string', 'id should be a string') assert.equal(await service.create(UNKNOWN_RESOURCE, post), undefined) }) await test('update', async () => { const obj = { f1: 'bar' } const res = await service.update(OBJECT, obj) assert.equal(res, obj) assert.equal( await service.update(UNKNOWN_RESOURCE, obj), undefined, 'should ignore unknown resources', ) assert.equal(await service.update(POSTS, {}), undefined, 'should ignore arrays') }) await test('patch', async () => { const obj = { f2: 'bar' } const res = await service.patch(OBJECT, obj) assert.deepEqual(res, { f1: 'foo', ...obj }) assert.equal( await service.patch(UNKNOWN_RESOURCE, obj), undefined, 'should ignore unknown resources', ) assert.equal(await service.patch(POSTS, {}), undefined, 'should ignore arrays') }) await test('updateById', async () => { const post = { id: 'xxx', title: 'updated post' } const res = await service.updateById(POSTS, post1.id, post) assert.equal(res?.['id'], post1.id, 'id should not change') assert.equal(res?.['title'], post.title) assert.equal(await service.updateById(UNKNOWN_RESOURCE, post1.id, post), undefined) assert.equal(await service.updateById(POSTS, UNKNOWN_ID, post), undefined) }) await test('patchById', async () => { const post = { id: 'xxx', title: 'updated post' } const res = await service.patchById(POSTS, post1.id, post) assert.notEqual(res, undefined) assert.equal(res?.['id'], post1.id) assert.equal(res?.['title'], post.title) assert.equal(await service.patchById(UNKNOWN_RESOURCE, post1.id, post), undefined) assert.equal(await service.patchById(POSTS, UNKNOWN_ID, post), undefined) }) await test('destroy', async (t) => { await t.test('nullifies foreign keys', async () => { const prevLength = Number(db.data?.[POSTS]?.length) || 0 await service.destroyById(POSTS, post1.id) assert.equal(db.data?.[POSTS]?.length, prevLength - 1) assert.deepEqual(db.data?.[COMMENTS], [{ ...comment1, postId: null }]) }) await t.test('deletes dependent resources', async () => { const prevLength = Number(db.data?.[POSTS]?.length) || 0 await service.destroyById(POSTS, post1.id, [COMMENTS]) assert.equal(db.data[POSTS].length, prevLength - 1) assert.equal(db.data[COMMENTS].length, 0) }) await t.test('ignores unknown resources', async () => { assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined) assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined) }) }) ================================================ FILE: src/service.ts ================================================ import inflection from 'inflection' import { Low } from 'lowdb' import sortOn from 'sort-on' import type { JsonObject } from 'type-fest' import { matchesWhere } from './matches-where.ts' import { paginate, type PaginationResult } from './paginate.ts' import { randomId } from './random-id.ts' export type Item = Record export type Data = Record export function isItem(obj: unknown): obj is Item { return typeof obj === 'object' && obj !== null && !Array.isArray(obj) } export type PaginatedItems = PaginationResult function ensureArray(arg: string | string[] = []): string[] { return Array.isArray(arg) ? arg : [arg] } function embed(db: Low, name: string, item: Item, related: string): Item { if (inflection.singularize(related) === related) { const relatedData = db.data[inflection.pluralize(related)] as Item[] if (!relatedData) { return item } const foreignKey = `${related}Id` const relatedItem = relatedData.find((relatedItem: Item) => { return relatedItem['id'] === item[foreignKey] }) return { ...item, [related]: relatedItem } } const relatedData: Item[] = db.data[related] as Item[] if (!relatedData) { return item } const foreignKey = `${inflection.singularize(name)}Id` const relatedItems = relatedData.filter( (relatedItem: Item) => relatedItem[foreignKey] === item['id'], ) return { ...item, [related]: relatedItems } } function nullifyForeignKey(db: Low, name: string, id: string) { const foreignKey = `${inflection.singularize(name)}Id` Object.entries(db.data).forEach(([key, items]) => { // Skip if (key === name) return // Nullify if (Array.isArray(items)) { items.forEach((item) => { if (item[foreignKey] === id) { item[foreignKey] = null } }) } }) } function deleteDependents(db: Low, name: string, dependents: string[]) { const foreignKey = `${inflection.singularize(name)}Id` Object.entries(db.data).forEach(([key, items]) => { // Skip if (key === name || !dependents.includes(key)) return // Delete if foreign key is null if (Array.isArray(items)) { db.data[key] = items.filter((item) => item[foreignKey] !== null) } }) } export class Service { #db: Low constructor(db: Low) { this.#db = db } #get(name: string): Item[] | Item | undefined { return this.#db.data[name] } has(name: string): boolean { return Object.prototype.hasOwnProperty.call(this.#db?.data, name) } findById(name: string, id: string, query: { _embed?: string[] | string }): Item | undefined { const value = this.#get(name) if (Array.isArray(value)) { let item = value.find((item) => item['id'] === id) ensureArray(query._embed).forEach((related) => { if (item !== undefined) item = embed(this.#db, name, item, related) }) return item } return } find( name: string, opts: { where: JsonObject sort?: string page?: number perPage?: number embed?: string | string[] }, ): Item[] | PaginatedItems | Item | undefined { const items = this.#get(name) if (!Array.isArray(items)) { return items } let results = items // Include ensureArray(opts.embed).forEach((related) => { results = results.map((item) => embed(this.#db, name, item, related)) }) results = results.filter((item) => matchesWhere(item as JsonObject, opts.where)) if (opts.sort) { results = sortOn(results, opts.sort.split(',')) } if (opts.page !== undefined) { return paginate(results, opts.page, opts.perPage ?? 10) } return results } async create(name: string, data: Omit = {}): Promise { const items = this.#get(name) if (items === undefined || !Array.isArray(items)) return const item = { id: randomId(), ...data } items.push(item) await this.#db.write() return item } async #updateOrPatch(name: string, body: Item = {}, isPatch: boolean): Promise { const item = this.#get(name) if (item === undefined || Array.isArray(item)) return const nextItem = (this.#db.data[name] = isPatch ? { ...item, ...body } : body) await this.#db.write() return nextItem } async #updateOrPatchById( name: string, id: string, body: Item = {}, isPatch: boolean, ): Promise { const items = this.#get(name) if (items === undefined || !Array.isArray(items)) return const item = items.find((item) => item['id'] === id) if (!item) return const nextItem = isPatch ? { ...item, ...body, id } : { ...body, id } const index = items.indexOf(item) items.splice(index, 1, nextItem) await this.#db.write() return nextItem } async update(name: string, body: Item = {}): Promise { return this.#updateOrPatch(name, body, false) } async patch(name: string, body: Item = {}): Promise { return this.#updateOrPatch(name, body, true) } async updateById(name: string, id: string, body: Item = {}): Promise { return this.#updateOrPatchById(name, id, body, false) } async patchById(name: string, id: string, body: Item = {}): Promise { return this.#updateOrPatchById(name, id, body, true) } async destroyById( name: string, id: string, dependent?: string | string[], ): Promise { const items = this.#get(name) if (items === undefined || !Array.isArray(items)) return const item = items.find((item) => item['id'] === id) if (item === undefined) return const index = items.indexOf(item) items.splice(index, 1) nullifyForeignKey(this.#db, name, id) const dependents = ensureArray(dependent) deleteDependents(this.#db, name, dependents) await this.#db.write() return item } } ================================================ FILE: src/where-operators.ts ================================================ export const WHERE_OPERATORS = [ 'lt', 'lte', 'gt', 'gte', 'eq', 'ne', 'in', 'contains', 'startsWith', 'endsWith', ] as const export type WhereOperator = (typeof WHERE_OPERATORS)[number] export function isWhereOperator(value: string): value is WhereOperator { return (WHERE_OPERATORS as readonly string[]).includes(value) } ================================================ FILE: tsconfig.json ================================================ { "exclude": ["src/**/*.test.ts"], "compilerOptions": { "outDir": "./lib", "target": "esnext", "module": "nodenext", "skipLibCheck": true, "strict": true, "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true } } ================================================ FILE: views/index.html ================================================ JSON Server <% const resources = Object.entries(it.data ?? {}); %>
Available REST resources from db.json.
Resources
<% if (resources.length === 0) { %> No resources in db.json. <% } else { %> <% resources.forEach(function([name, value]) { const isCollection = Array.isArray(value); %> /<%= name %> <% if (isCollection) { %> <%= value.length %> items <% } else { %> object <% } %> <% }) %> <% } %>
To replace this page, create public/index.html.