├── .husky └── pre-commit ├── .github ├── FUNDING.yml └── workflows │ ├── node.js.yml │ └── npm-publish.yml ├── .gitattributes ├── public └── test.html ├── .gitignore ├── .prettier.config.js ├── tsconfig.json ├── eslint.config.js ├── fixtures ├── db.json └── db.json5 ├── src ├── observer.ts ├── app.ts ├── app.test.ts ├── bin.ts ├── service.test.ts └── service.ts ├── CONTRIBUTING.md ├── package.json ├── LICENSE ├── views └── index.html └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: typicode 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.ts linguist-language=JavaScript -------------------------------------------------------------------------------- /public/test.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.log 2 | .DS_Store 3 | .idea 4 | lib 5 | node_modules 6 | public/output.css 7 | tmp 8 | -------------------------------------------------------------------------------- /.prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | semi: false, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "exclude": ["src/**/*.test.ts"], 4 | "compilerOptions": { 5 | "outDir": "./lib" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | 6 | export default [ 7 | {files: ["**/*.{js,mjs,cjs,ts}"]}, 8 | {languageOptions: { globals: globals.node }}, 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | ]; -------------------------------------------------------------------------------- /fixtures/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "posts": [ 3 | { "id": "1", "title": "a title" }, 4 | { "id": "2", "title": "another title" } 5 | ], 6 | "comments": [ 7 | { "id": "1", "text": "a comment about post 1", "postId": "1" }, 8 | { "id": "2", "text": "another comment about post 1", "postId": "1" } 9 | ], 10 | "profile": { 11 | "name": "typicode" 12 | } 13 | } -------------------------------------------------------------------------------- /fixtures/db.json5: -------------------------------------------------------------------------------- 1 | { 2 | posts: [ 3 | { 4 | id: '1', 5 | title: 'a title', 6 | }, 7 | { 8 | id: '2', 9 | title: 'another title', 10 | }, 11 | ], 12 | comments: [ 13 | { 14 | id: '1', 15 | text: 'a comment about post 1', 16 | postId: '1', 17 | }, 18 | { 19 | id: '2', 20 | text: 'another comment about post 1', 21 | postId: '1', 22 | }, 23 | ], 24 | profile: { 25 | name: 'typicode', 26 | }, 27 | } -------------------------------------------------------------------------------- /src/observer.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from 'lowdb' 2 | 3 | // Lowdb adapter to observe read/write events 4 | export class Observer { 5 | #adapter 6 | 7 | onReadStart = function () { 8 | return 9 | } 10 | onReadEnd: (data: T | null) => void = function () { 11 | return 12 | } 13 | onWriteStart = function () { 14 | return 15 | } 16 | onWriteEnd = function () { 17 | return 18 | } 19 | 20 | constructor(adapter: Adapter) { 21 | this.#adapter = adapter 22 | } 23 | 24 | async read() { 25 | this.onReadStart() 26 | const data = await this.#adapter.read() 27 | this.onReadEnd(data) 28 | return data 29 | } 30 | 31 | async write(arg: T) { 32 | this.onWriteStart() 33 | await this.#adapter.write(arg) 34 | this.onWriteEnd() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | - run: npm ci 22 | - run: npm test 23 | 24 | publish-npm: 25 | needs: build 26 | runs-on: ubuntu-latest 27 | permissions: 28 | id-token: write 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | registry-url: https://registry.npmjs.org/ 35 | - run: npm ci 36 | - run: npm publish --provenance --access public 37 | env: 38 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Agreement 2 | 3 | Thanks for your interest in contributing! 4 | 5 | By contributing to this project, you agree to the following: 6 | 7 | 1. **Relicensing:** to support the project's sustainability and ensure it's longevity, your contributions can be relicensed to any license. 8 | 9 | 2. **Ownership Rights:** You confirm you own the rights to your contributed code. 10 | 11 | 3. **Disagreement:** If you disagree with these terms, please create an issue instead. I'll handle the bug or feature request. 12 | 13 | 4. **Benefits for Contributors:** If your contribution is merged, you'll enjoy the same benefits as a sponsor for one year. This includes using the project in a company context, free of charge. 14 | 15 | ## Fair Source License 16 | 17 | This project uses the Fair Source License, which is neither purely open-source nor closed-source. It allows visibility of the source code and free usage for a limited number of two users within an organization. Beyond this limit (three or more users), a small licensing fee via [GitHub Sponsors](https://github.com/sponsors/typicode) applies. 18 | 19 | This doesn't apply to individuals, students, teachers, small teams, ... 20 | 21 | Got questions or need support? Feel free to reach out to typicode@gmail.com. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-server", 3 | "version": "1.0.0-beta.3", 4 | "description": "", 5 | "type": "module", 6 | "bin": { 7 | "json-server": "lib/bin.js" 8 | }, 9 | "types": "lib", 10 | "files": [ 11 | "lib", 12 | "views" 13 | ], 14 | "engines": { 15 | "node": ">=18.3" 16 | }, 17 | "scripts": { 18 | "dev": "tsx watch src/bin.ts fixtures/db.json", 19 | "build": "rm -rf lib && tsc", 20 | "test": "node --import tsx/esm --test src/*.test.ts", 21 | "lint": "eslint src", 22 | "prepare": "husky", 23 | "prepublishOnly": "npm run build" 24 | }, 25 | "keywords": [], 26 | "author": "typicode ", 27 | "license": "SEE LICENSE IN ./LICENSE", 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/typicode/json-server.git" 31 | }, 32 | "devDependencies": { 33 | "@eslint/js": "^9.11.0", 34 | "@sindresorhus/tsconfig": "^6.0.0", 35 | "@tailwindcss/typography": "^0.5.15", 36 | "@types/node": "^22.5.5", 37 | "concurrently": "^9.0.1", 38 | "eslint": "^9.11.0", 39 | "get-port": "^7.1.0", 40 | "globals": "^15.9.0", 41 | "husky": "^9.1.6", 42 | "tempy": "^3.1.0", 43 | "tsx": "^4.19.1", 44 | "type-fest": "^4.26.1", 45 | "typescript": "^5.6.2", 46 | "typescript-eslint": "^8.6.0" 47 | }, 48 | "dependencies": { 49 | "@tinyhttp/app": "^2.4.0", 50 | "@tinyhttp/cors": "^2.0.1", 51 | "@tinyhttp/logger": "^2.0.0", 52 | "chalk": "^5.3.0", 53 | "chokidar": "^4.0.1", 54 | "dot-prop": "^9.0.0", 55 | "eta": "^3.5.0", 56 | "inflection": "^3.0.0", 57 | "json5": "^2.2.3", 58 | "lowdb": "^7.0.1", 59 | "milliparsec": "^4.0.0", 60 | "sirv": "^2.0.4", 61 | "sort-on": "^6.1.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Fair Source License, version 0.9 2 | 3 | Copyright (C) 2023-present typicode 4 | 5 | Licensor: typicode 6 | 7 | Software: json-server 8 | 9 | Use Limitation: 2 users 10 | 11 | License Grant. Licensor hereby grants to each recipient of the 12 | Software ("you") a non-exclusive, non-transferable, royalty-free and 13 | fully-paid-up license, under all of the Licensor's copyright and 14 | patent rights, to use, copy, distribute, prepare derivative works of, 15 | publicly perform and display the Software, subject to the Use 16 | Limitation and the conditions set forth below. 17 | 18 | Use Limitation. The license granted above allows use by up to the 19 | number of users per entity set forth above (the "Use Limitation"). For 20 | determining the number of users, "you" includes all affiliates, 21 | meaning legal entities controlling, controlled by, or under common 22 | control with you. If you exceed the Use Limitation, your use is 23 | subject to payment of Licensor's then-current list price for licenses. 24 | 25 | Conditions. Redistribution in source code or other forms must include 26 | a copy of this license document to be provided in a reasonable 27 | manner. Any redistribution of the Software is only allowed subject to 28 | this license. 29 | 30 | Trademarks. This license does not grant you any right in the 31 | trademarks, service marks, brand names or logos of Licensor. 32 | 33 | DISCLAIMER. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OR 34 | CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES 35 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 36 | NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN 37 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 38 | CONNECTION WITH THE SOFTWARE. 39 | 40 | Termination. If you violate the terms of this license, your rights 41 | will terminate automatically and will not be reinstated without the 42 | prior written consent of Licensor. Any such termination will not 43 | affect the right of others who may have received copies of the 44 | Software from you. 45 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 64 | 65 | 66 | 67 |
68 | 75 |
76 |
77 |

✧*。٩(ˊᗜˋ*)و✧*。

78 | <% if (Object.keys(it.data).length===0) { %> 79 |

No resources found in JSON file

80 | <% } %> 81 | <% Object.entries(it.data).forEach(function([name]) { %> 82 |
    83 |
  • 84 | /<%= name %> 85 | 86 | <% if (Array.isArray(it.data[name])) { %> 87 | - <%= it.data[name].length %> 88 | <%= it.data[name].length> 1 ? 'items' : 'item' %> 89 | 90 | <% } %> 91 |
  • 92 |
93 | <% }) %> 94 |
95 | 96 | 97 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { dirname, isAbsolute, join } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | import { App, type Request } from '@tinyhttp/app' 5 | import { cors } from '@tinyhttp/cors' 6 | import { Eta } from 'eta' 7 | import { Low } from 'lowdb' 8 | import { json } from 'milliparsec' 9 | import sirv from 'sirv' 10 | 11 | import { Data, isItem, Service } from './service.js' 12 | 13 | const __dirname = dirname(fileURLToPath(import.meta.url)) 14 | const isProduction = process.env['NODE_ENV'] === 'production' 15 | 16 | type QueryValue = Request['query'][string] | number 17 | type Query = Record 18 | 19 | export type AppOptions = { 20 | logger?: boolean 21 | static?: string[] 22 | } 23 | 24 | const eta = new Eta({ 25 | views: join(__dirname, '../views'), 26 | cache: isProduction, 27 | }) 28 | 29 | export function createApp(db: Low, options: AppOptions = {}) { 30 | // Create service 31 | const service = new Service(db) 32 | 33 | // Create app 34 | const app = new App() 35 | 36 | // Static files 37 | app.use(sirv('public', { dev: !isProduction })) 38 | options.static 39 | ?.map((path) => (isAbsolute(path) ? path : join(process.cwd(), path))) 40 | .forEach((dir) => app.use(sirv(dir, { dev: !isProduction }))) 41 | 42 | // CORS 43 | app 44 | .use((req, res, next) => { 45 | return cors({ 46 | allowedHeaders: req.headers['access-control-request-headers'] 47 | ?.split(',') 48 | .map((h) => h.trim()), 49 | })(req, res, next) 50 | }) 51 | .options('*', cors()) 52 | 53 | // Body parser 54 | // @ts-expect-error expected 55 | app.use(json()) 56 | 57 | app.get('/', (_req, res) => 58 | res.send(eta.render('index.html', { data: db.data })), 59 | ) 60 | 61 | app.get('/:name', (req, res, next) => { 62 | const { name = '' } = req.params 63 | const query: Query = {} 64 | 65 | Object.keys(req.query).forEach((key) => { 66 | let value: QueryValue = req.query[key] 67 | 68 | if ( 69 | ['_start', '_end', '_limit', '_page', '_per_page'].includes(key) && 70 | typeof value === 'string' 71 | ) { 72 | value = parseInt(value); 73 | } 74 | 75 | if (!Number.isNaN(value)) { 76 | query[key] = value; 77 | } 78 | }) 79 | res.locals['data'] = service.find(name, query) 80 | next?.() 81 | }) 82 | 83 | app.get('/:name/:id', (req, res, next) => { 84 | const { name = '', id = '' } = req.params 85 | res.locals['data'] = service.findById(name, id, req.query) 86 | next?.() 87 | }) 88 | 89 | app.post('/:name', async (req, res, next) => { 90 | const { name = '' } = req.params 91 | if (isItem(req.body)) { 92 | res.locals['data'] = await service.create(name, req.body) 93 | } 94 | next?.() 95 | }) 96 | 97 | app.put('/:name', async (req, res, next) => { 98 | const { name = '' } = req.params 99 | if (isItem(req.body)) { 100 | res.locals['data'] = await service.update(name, req.body) 101 | } 102 | next?.() 103 | }) 104 | 105 | app.put('/:name/:id', async (req, res, next) => { 106 | const { name = '', id = '' } = req.params 107 | if (isItem(req.body)) { 108 | res.locals['data'] = await service.updateById(name, id, req.body) 109 | } 110 | next?.() 111 | }) 112 | 113 | app.patch('/:name', async (req, res, next) => { 114 | const { name = '' } = req.params 115 | if (isItem(req.body)) { 116 | res.locals['data'] = await service.patch(name, req.body) 117 | } 118 | next?.() 119 | }) 120 | 121 | app.patch('/:name/:id', async (req, res, next) => { 122 | const { name = '', id = '' } = req.params 123 | if (isItem(req.body)) { 124 | res.locals['data'] = await service.patchById(name, id, req.body) 125 | } 126 | next?.() 127 | }) 128 | 129 | app.delete('/:name/:id', async (req, res, next) => { 130 | const { name = '', id = '' } = req.params 131 | res.locals['data'] = await service.destroyById( 132 | name, 133 | id, 134 | req.query['_dependent'], 135 | ) 136 | next?.() 137 | }) 138 | 139 | app.use('/:name', (req, res) => { 140 | const { data } = res.locals 141 | if (data === undefined) { 142 | res.sendStatus(404) 143 | } else { 144 | if (req.method === 'POST') res.status(201) 145 | res.json(data) 146 | } 147 | }) 148 | 149 | return app 150 | } 151 | -------------------------------------------------------------------------------- /src/app.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import { writeFileSync } from 'node:fs' 3 | import { join } from 'node:path' 4 | import test from 'node:test' 5 | 6 | import getPort from 'get-port' 7 | import { Low, Memory } from 'lowdb' 8 | import { temporaryDirectory } from 'tempy' 9 | 10 | import { createApp } from './app.js' 11 | import { Data } from './service.js' 12 | 13 | type Test = { 14 | 15 | method: HTTPMethods 16 | url: string 17 | statusCode: number 18 | } 19 | 20 | type HTTPMethods = 21 | | 'DELETE' 22 | | 'GET' 23 | | 'HEAD' 24 | | 'PATCH' 25 | | 'POST' 26 | | 'PUT' 27 | | 'OPTIONS' 28 | 29 | const port = await getPort() 30 | 31 | // Create custom static dir with an html file 32 | const tmpDir = temporaryDirectory() 33 | const file = 'file.html' 34 | writeFileSync(join(tmpDir, file), 'utf-8') 35 | 36 | // Create app 37 | const db = new Low(new Memory(), {}) 38 | db.data = { 39 | posts: [{ id: '1', title: 'foo' }], 40 | comments: [{ id: '1', postId: '1' }], 41 | object: { f1: 'foo' }, 42 | } 43 | const app = createApp(db, { static: [tmpDir] }) 44 | 45 | await new Promise((resolve, reject) => { 46 | try { 47 | const server = app.listen(port, () => resolve()) 48 | test.after(() => server.close()) 49 | } catch (err) { 50 | reject(err) 51 | } 52 | }) 53 | 54 | await test('createApp', async (t) => { 55 | // URLs 56 | const POSTS = '/posts' 57 | const POSTS_WITH_COMMENTS = '/posts?_embed=comments' 58 | const POST_1 = '/posts/1' 59 | const POST_NOT_FOUND = '/posts/-1' 60 | const POST_WITH_COMMENTS = '/posts/1?_embed=comments' 61 | const COMMENTS = '/comments' 62 | const POST_COMMENTS = '/comments?postId=1' 63 | const NOT_FOUND = '/not-found' 64 | const OBJECT = '/object' 65 | const OBJECT_1 = '/object/1' 66 | 67 | const arr: Test[] = [ 68 | // Static 69 | { method: 'GET', url: '/', statusCode: 200 }, 70 | { method: 'GET', url: '/test.html', statusCode: 200 }, 71 | { method: 'GET', url: `/${file}`, statusCode: 200 }, 72 | 73 | // CORS 74 | { method: 'OPTIONS', url: POSTS, statusCode: 204 }, 75 | 76 | // API 77 | { method: 'GET', url: POSTS, statusCode: 200 }, 78 | { method: 'GET', url: POSTS_WITH_COMMENTS, statusCode: 200 }, 79 | { method: 'GET', url: POST_1, statusCode: 200 }, 80 | { method: 'GET', url: POST_NOT_FOUND, statusCode: 404 }, 81 | { method: 'GET', url: POST_WITH_COMMENTS, statusCode: 200 }, 82 | { method: 'GET', url: COMMENTS, statusCode: 200 }, 83 | { method: 'GET', url: POST_COMMENTS, statusCode: 200 }, 84 | { method: 'GET', url: OBJECT, statusCode: 200 }, 85 | { method: 'GET', url: OBJECT_1, statusCode: 404 }, 86 | { method: 'GET', url: NOT_FOUND, statusCode: 404 }, 87 | 88 | { method: 'POST', url: POSTS, statusCode: 201 }, 89 | { method: 'POST', url: POST_1, statusCode: 404 }, 90 | { method: 'POST', url: POST_NOT_FOUND, statusCode: 404 }, 91 | { method: 'POST', url: OBJECT, statusCode: 404 }, 92 | { method: 'POST', url: OBJECT_1, statusCode: 404 }, 93 | { method: 'POST', url: NOT_FOUND, statusCode: 404 }, 94 | 95 | { method: 'PUT', url: POSTS, statusCode: 404 }, 96 | { method: 'PUT', url: POST_1, statusCode: 200 }, 97 | { method: 'PUT', url: OBJECT, statusCode: 200 }, 98 | { method: 'PUT', url: OBJECT_1, statusCode: 404 }, 99 | { method: 'PUT', url: POST_NOT_FOUND, statusCode: 404 }, 100 | { method: 'PUT', url: NOT_FOUND, statusCode: 404 }, 101 | 102 | { method: 'PATCH', url: POSTS, statusCode: 404 }, 103 | { method: 'PATCH', url: POST_1, statusCode: 200 }, 104 | { method: 'PATCH', url: OBJECT, statusCode: 200 }, 105 | { method: 'PATCH', url: OBJECT_1, statusCode: 404 }, 106 | { method: 'PATCH', url: POST_NOT_FOUND, statusCode: 404 }, 107 | { method: 'PATCH', url: NOT_FOUND, statusCode: 404 }, 108 | 109 | { method: 'DELETE', url: POSTS, statusCode: 404 }, 110 | { method: 'DELETE', url: POST_1, statusCode: 200 }, 111 | { method: 'DELETE', url: OBJECT, statusCode: 404 }, 112 | { method: 'DELETE', url: OBJECT_1, statusCode: 404 }, 113 | { method: 'DELETE', url: POST_NOT_FOUND, statusCode: 404 }, 114 | { method: 'DELETE', url: NOT_FOUND, statusCode: 404 }, 115 | ] 116 | 117 | for (const tc of arr) { 118 | await t.test(`${tc.method} ${tc.url}`, async () => { 119 | const response = await fetch(`http://localhost:${port}${tc.url}`, { 120 | method: tc.method, 121 | }) 122 | assert.equal( 123 | response.status, 124 | tc.statusCode, 125 | `${response.status} !== ${tc.statusCode} ${tc.method} ${tc.url} failed`, 126 | ) 127 | }) 128 | } 129 | }) 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON-Server 2 | 3 | [![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) 4 | 5 | > [!IMPORTANT] 6 | > Viewing beta v1 documentation – usable but expect breaking changes. For stable version, see [here](https://github.com/typicode/json-server/tree/v0) 7 | 8 | > [!NOTE] 9 | > Using React ⚛️ ? Check my new project [MistCSS](https://github.com/typicode/mistcss) to write type-safe styles (works with TailwindCSS) 10 | 11 | ## Install 12 | 13 | ```shell 14 | npm install json-server 15 | ``` 16 | 17 | ## Usage 18 | 19 | Create a `db.json` or `db.json5` file 20 | 21 | ```json 22 | { 23 | "posts": [ 24 | { "id": "1", "title": "a title", "views": 100 }, 25 | { "id": "2", "title": "another title", "views": 200 } 26 | ], 27 | "comments": [ 28 | { "id": "1", "text": "a comment about post 1", "postId": "1" }, 29 | { "id": "2", "text": "another comment about post 1", "postId": "1" } 30 | ], 31 | "profile": { 32 | "name": "typicode" 33 | } 34 | } 35 | ``` 36 | 37 |
38 | 39 | View db.json5 example 40 | 41 | ```json5 42 | { 43 | posts: [ 44 | { id: '1', title: 'a title', views: 100 }, 45 | { id: '2', title: 'another title', views: 200 }, 46 | ], 47 | comments: [ 48 | { id: '1', text: 'a comment about post 1', postId: '1' }, 49 | { id: '2', text: 'another comment about post 1', postId: '1' }, 50 | ], 51 | profile: { 52 | name: 'typicode', 53 | }, 54 | } 55 | ``` 56 | 57 | You can read more about JSON5 format [here](https://github.com/json5/json5). 58 | 59 |
60 | 61 | Pass it to JSON Server CLI 62 | 63 | ```shell 64 | $ npx json-server db.json 65 | ``` 66 | 67 | Get a REST API 68 | 69 | ```shell 70 | $ curl http://localhost:3000/posts/1 71 | { 72 | "id": "1", 73 | "title": "a title", 74 | "views": 100 75 | } 76 | ``` 77 | 78 | Run `json-server --help` for a list of options 79 | 80 | ## Sponsors ✨ 81 | 82 | ### Gold 83 | 84 | || 85 | | :---: | 86 | | | 87 | | | 88 | | | 89 | 90 | ### Silver 91 | 92 | || 93 | | :---: | 94 | | | 95 | 96 | ### Bronze 97 | 98 | ||| 99 | | :---: | :---: | 100 | | | | 101 | 102 | [Become a sponsor and have your company logo here](https://github.com/users/typicode/sponsorship) 103 | 104 | ## Sponsorware 105 | 106 | > [!NOTE] 107 | > This project uses the [Fair Source License](https://fair.io/). Only organizations with 3+ users are kindly asked to contribute a small amount through sponsorship [sponsor](https://github.com/sponsors/typicode) for usage. __This license helps keep the project sustainable and healthy, benefiting everyone.__ 108 | > 109 | > For more information, FAQs, and the rationale behind this, visit [https://fair.io/](https://fair.io/). 110 | 111 | ## Routes 112 | 113 | Based on the example `db.json`, you'll get the following routes: 114 | 115 | ``` 116 | GET /posts 117 | GET /posts/:id 118 | POST /posts 119 | PUT /posts/:id 120 | PATCH /posts/:id 121 | DELETE /posts/:id 122 | 123 | # Same for comments 124 | ``` 125 | 126 | ``` 127 | GET /profile 128 | PUT /profile 129 | PATCH /profile 130 | ``` 131 | 132 | ## Params 133 | 134 | ### Conditions 135 | 136 | - ` ` → `==` 137 | - `lt` → `<` 138 | - `lte` → `<=` 139 | - `gt` → `>` 140 | - `gte` → `>=` 141 | - `ne` → `!=` 142 | 143 | ``` 144 | GET /posts?views_gt=9000 145 | ``` 146 | 147 | ### Range 148 | 149 | - `start` 150 | - `end` 151 | - `limit` 152 | 153 | ``` 154 | GET /posts?_start=10&_end=20 155 | GET /posts?_start=10&_limit=10 156 | ``` 157 | 158 | ### Paginate 159 | 160 | - `page` 161 | - `per_page` (default = 10) 162 | 163 | ``` 164 | GET /posts?_page=1&_per_page=25 165 | ``` 166 | 167 | ### Sort 168 | 169 | - `_sort=f1,f2` 170 | 171 | ``` 172 | GET /posts?_sort=id,-views 173 | ``` 174 | 175 | ### Nested and array fields 176 | 177 | - `x.y.z...` 178 | - `x.y.z[i]...` 179 | 180 | ``` 181 | GET /foo?a.b=bar 182 | GET /foo?x.y_lt=100 183 | GET /foo?arr[0]=bar 184 | ``` 185 | 186 | ### Embed 187 | 188 | ``` 189 | GET /posts?_embed=comments 190 | GET /comments?_embed=post 191 | ``` 192 | 193 | ## Delete 194 | 195 | ``` 196 | DELETE /posts/1 197 | DELETE /posts/1?_dependent=comments 198 | ``` 199 | 200 | ## Serving static files 201 | 202 | If you create a `./public` directory, JSON Server will serve its content in addition to the REST API. 203 | 204 | You can also add custom directories using `-s/--static` option. 205 | 206 | ```sh 207 | json-server -s ./static 208 | json-server -s ./static -s ./node_modules 209 | ``` 210 | 211 | ## Notable differences with v0.17 212 | 213 | - `id` is always a string and will be generated for you if missing 214 | - use `_per_page` with `_page` instead of `_limit`for pagination 215 | - use Chrome's `Network tab > throtling` to delay requests instead of `--delay` CLI option 216 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { existsSync, readFileSync, writeFileSync } from 'node:fs' 3 | import { extname } from 'node:path' 4 | import { parseArgs } from 'node:util' 5 | 6 | import chalk from 'chalk' 7 | import { watch } from 'chokidar' 8 | import JSON5 from 'json5' 9 | import { Adapter, Low } from 'lowdb' 10 | import { DataFile, JSONFile } from 'lowdb/node' 11 | import { PackageJson } from 'type-fest' 12 | 13 | import { fileURLToPath } from 'node:url' 14 | import { createApp } from './app.js' 15 | import { Observer } from './observer.js' 16 | import { Data } from './service.js' 17 | 18 | function help() { 19 | console.log(`Usage: json-server [options] 20 | 21 | Options: 22 | -p, --port Port (default: 3000) 23 | -h, --host Host (default: localhost) 24 | -s, --static Static files directory (multiple allowed) 25 | --help Show this message 26 | --version Show version number 27 | `) 28 | } 29 | 30 | // Parse args 31 | function args(): { 32 | file: string 33 | port: number 34 | host: string 35 | static: string[] 36 | } { 37 | try { 38 | const { values, positionals } = parseArgs({ 39 | options: { 40 | port: { 41 | type: 'string', 42 | short: 'p', 43 | default: process.env['PORT'] ?? '3000', 44 | }, 45 | host: { 46 | type: 'string', 47 | short: 'h', 48 | default: process.env['HOST'] ?? 'localhost', 49 | }, 50 | static: { 51 | type: 'string', 52 | short: 's', 53 | multiple: true, 54 | default: [], 55 | }, 56 | help: { 57 | type: 'boolean', 58 | }, 59 | version: { 60 | type: 'boolean', 61 | }, 62 | // Deprecated 63 | watch: { 64 | type: 'boolean', 65 | short: 'w', 66 | }, 67 | }, 68 | allowPositionals: true, 69 | }) 70 | 71 | // --version 72 | if (values.version) { 73 | const pkg = JSON.parse( 74 | readFileSync( 75 | fileURLToPath(new URL('../package.json', import.meta.url)), 76 | 'utf-8', 77 | ), 78 | ) as PackageJson 79 | console.log(pkg.version) 80 | process.exit() 81 | } 82 | 83 | // Handle --watch 84 | if (values.watch) { 85 | console.log( 86 | chalk.yellow( 87 | '--watch/-w can be omitted, JSON Server 1+ watches for file changes by default', 88 | ), 89 | ) 90 | } 91 | 92 | if (values.help || positionals.length === 0) { 93 | help() 94 | process.exit() 95 | } 96 | 97 | // App args and options 98 | return { 99 | file: positionals[0] ?? '', 100 | port: parseInt(values.port as string), 101 | host: values.host as string, 102 | static: values.static as string[], 103 | } 104 | } catch (e) { 105 | if ((e as NodeJS.ErrnoException).code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') { 106 | console.log(chalk.red((e as NodeJS.ErrnoException).message.split('.')[0])) 107 | help() 108 | process.exit(1) 109 | } else { 110 | throw e 111 | } 112 | } 113 | } 114 | 115 | const { file, port, host, static: staticArr } = args() 116 | 117 | if (!existsSync(file)) { 118 | console.log(chalk.red(`File ${file} not found`)) 119 | process.exit(1) 120 | } 121 | 122 | // Handle empty string JSON file 123 | if (readFileSync(file, 'utf-8').trim() === '') { 124 | writeFileSync(file, '{}') 125 | } 126 | 127 | // Set up database 128 | let adapter: Adapter 129 | if (extname(file) === '.json5') { 130 | adapter = new DataFile(file, { 131 | parse: JSON5.parse, 132 | stringify: JSON5.stringify, 133 | }) 134 | } else { 135 | adapter = new JSONFile(file) 136 | } 137 | const observer = new Observer(adapter) 138 | 139 | const db = new Low(observer, {}) 140 | await db.read() 141 | 142 | // Create app 143 | const app = createApp(db, { logger: false, static: staticArr }) 144 | 145 | function logRoutes(data: Data) { 146 | console.log(chalk.bold('Endpoints:')) 147 | if (Object.keys(data).length === 0) { 148 | console.log( 149 | chalk.gray(`No endpoints found, try adding some data to ${file}`), 150 | ) 151 | return 152 | } 153 | console.log( 154 | Object.keys(data) 155 | .map( 156 | (key) => `${chalk.gray(`http://${host}:${port}/`)}${chalk.blue(key)}`, 157 | ) 158 | .join('\n'), 159 | ) 160 | } 161 | 162 | const kaomojis = ['♡⸜(˶˃ ᵕ ˂˶)⸝♡', '♡( ◡‿◡ )', '( ˶ˆ ᗜ ˆ˵ )', '(˶ᵔ ᵕ ᵔ˶)'] 163 | 164 | function randomItem(items: string[]): string { 165 | const index = Math.floor(Math.random() * items.length) 166 | return items.at(index) ?? '' 167 | } 168 | 169 | app.listen(port, () => { 170 | console.log( 171 | [ 172 | chalk.bold(`JSON Server started on PORT :${port}`), 173 | chalk.gray('Press CTRL-C to stop'), 174 | chalk.gray(`Watching ${file}...`), 175 | '', 176 | chalk.magenta(randomItem(kaomojis)), 177 | '', 178 | chalk.bold('Index:'), 179 | chalk.gray(`http://localhost:${port}/`), 180 | '', 181 | chalk.bold('Static files:'), 182 | chalk.gray('Serving ./public directory if it exists'), 183 | '', 184 | ].join('\n'), 185 | ) 186 | logRoutes(db.data) 187 | }) 188 | 189 | // Watch file for changes 190 | if (process.env['NODE_ENV'] !== 'production') { 191 | let writing = false // true if the file is being written to by the app 192 | let prevEndpoints = '' 193 | 194 | observer.onWriteStart = () => { 195 | writing = true 196 | } 197 | observer.onWriteEnd = () => { 198 | writing = false 199 | } 200 | observer.onReadStart = () => { 201 | prevEndpoints = JSON.stringify(Object.keys(db.data).sort()) 202 | } 203 | observer.onReadEnd = (data) => { 204 | if (data === null) { 205 | return 206 | } 207 | 208 | const nextEndpoints = JSON.stringify(Object.keys(data).sort()) 209 | if (prevEndpoints !== nextEndpoints) { 210 | console.log() 211 | logRoutes(data) 212 | } 213 | } 214 | watch(file).on('change', () => { 215 | // Do no reload if the file is being written to by the app 216 | if (!writing) { 217 | db.read().catch((e) => { 218 | if (e instanceof SyntaxError) { 219 | return console.log( 220 | chalk.red(['', `Error parsing ${file}`, e.message].join('\n')), 221 | ) 222 | } 223 | console.log(e) 224 | }) 225 | } 226 | }) 227 | } 228 | -------------------------------------------------------------------------------- /src/service.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import test from 'node:test' 3 | 4 | import { Low, Memory } from 'lowdb' 5 | 6 | import { Data, Item, PaginatedItems, Service } from './service.js' 7 | 8 | const defaultData = { posts: [], comments: [], object: {} } 9 | const adapter = new Memory() 10 | const db = new Low(adapter, defaultData) 11 | const service = new Service(db) 12 | 13 | const POSTS = 'posts' 14 | const COMMENTS = 'comments' 15 | const OBJECT = 'object' 16 | 17 | const UNKNOWN_RESOURCE = 'xxx' 18 | const UNKNOWN_ID = 'xxx' 19 | 20 | const post1 = { 21 | id: '1', 22 | title: 'a', 23 | views: 100, 24 | published: true, 25 | author: { name: 'foo' }, 26 | tags: ['foo', 'bar'], 27 | } 28 | const post2 = { 29 | id: '2', 30 | title: 'b', 31 | views: 200, 32 | published: false, 33 | author: { name: 'bar' }, 34 | tags: ['bar'], 35 | } 36 | const post3 = { 37 | id: '3', 38 | title: 'c', 39 | views: 300, 40 | published: false, 41 | author: { name: 'baz' }, 42 | tags: ['foo'], 43 | } 44 | const comment1 = { id: '1', title: 'a', postId: '1' } 45 | const items = 3 46 | 47 | const obj = { 48 | f1: 'foo', 49 | } 50 | 51 | function reset() { 52 | db.data = structuredClone({ 53 | posts: [post1, post2, post3], 54 | comments: [comment1], 55 | object: obj, 56 | }) 57 | } 58 | 59 | await test('constructor', () => { 60 | const defaultData = { posts: [{ id: '1' }, {}], object: {} } satisfies Data 61 | const db = new Low(adapter, defaultData) 62 | new Service(db) 63 | if (Array.isArray(db.data['posts'])) { 64 | const id0 = db.data['posts']?.at(0)?.['id'] 65 | const id1 = db.data['posts']?.at(1)?.['id'] 66 | assert.ok( 67 | typeof id1 === 'string' && id1.length > 0, 68 | `id should be a non empty string but was: ${String(id1)}`, 69 | ) 70 | assert.ok( 71 | typeof id0 === 'string' && id0 === '1', 72 | `id should not change if already set but was: ${String(id0)}`, 73 | ) 74 | } 75 | }) 76 | 77 | await test('findById', () => { 78 | reset() 79 | if (!Array.isArray(db.data?.[POSTS])) 80 | throw new Error('posts should be an array') 81 | assert.deepEqual(service.findById(POSTS, '1', {}), db.data?.[POSTS]?.[0]) 82 | assert.equal(service.findById(POSTS, UNKNOWN_ID, {}), undefined) 83 | assert.deepEqual(service.findById(POSTS, '1', { _embed: ['comments'] }), { 84 | ...post1, 85 | comments: [comment1], 86 | }) 87 | assert.deepEqual(service.findById(COMMENTS, '1', { _embed: ['post'] }), { 88 | ...comment1, 89 | post: post1, 90 | }) 91 | assert.equal(service.findById(UNKNOWN_RESOURCE, '1', {}), undefined) 92 | }) 93 | 94 | await test('find', async (t) => { 95 | const arr: { 96 | data?: Data 97 | name: string 98 | params?: Parameters[1] 99 | res: Item | Item[] | PaginatedItems | undefined 100 | error?: Error 101 | }[] = [ 102 | { 103 | name: POSTS, 104 | res: [post1, post2, post3], 105 | }, 106 | { 107 | name: POSTS, 108 | params: { id: post1.id }, 109 | res: [post1], 110 | }, 111 | { 112 | name: POSTS, 113 | params: { id: UNKNOWN_ID }, 114 | res: [], 115 | }, 116 | { 117 | name: POSTS, 118 | params: { views: post1.views.toString() }, 119 | res: [post1], 120 | }, 121 | { 122 | name: POSTS, 123 | params: { 'author.name': post1.author.name }, 124 | res: [post1], 125 | }, 126 | { 127 | name: POSTS, 128 | params: { 'tags[0]': 'foo' }, 129 | res: [post1, post3], 130 | }, 131 | { 132 | name: POSTS, 133 | params: { id: UNKNOWN_ID, views: post1.views.toString() }, 134 | res: [], 135 | }, 136 | { 137 | name: POSTS, 138 | params: { views_ne: post1.views.toString() }, 139 | res: [post2, post3], 140 | }, 141 | { 142 | name: POSTS, 143 | params: { views_lt: (post1.views + 1).toString() }, 144 | res: [post1], 145 | }, 146 | { 147 | name: POSTS, 148 | params: { views_lt: post1.views.toString() }, 149 | res: [], 150 | }, 151 | { 152 | name: POSTS, 153 | params: { views_lte: post1.views.toString() }, 154 | res: [post1], 155 | }, 156 | { 157 | name: POSTS, 158 | params: { views_gt: post1.views.toString() }, 159 | res: [post2, post3], 160 | }, 161 | { 162 | name: POSTS, 163 | params: { views_gt: (post1.views - 1).toString() }, 164 | res: [post1, post2, post3], 165 | }, 166 | { 167 | name: POSTS, 168 | params: { views_gte: post1.views.toString() }, 169 | res: [post1, post2, post3], 170 | }, 171 | { 172 | name: POSTS, 173 | params: { 174 | views_gt: post1.views.toString(), 175 | views_lt: post3.views.toString(), 176 | }, 177 | res: [post2], 178 | }, 179 | { 180 | data: { posts: [post3, post1, post2] }, 181 | name: POSTS, 182 | params: { _sort: 'views' }, 183 | res: [post1, post2, post3], 184 | }, 185 | { 186 | data: { posts: [post3, post1, post2] }, 187 | name: POSTS, 188 | params: { _sort: '-views' }, 189 | res: [post3, post2, post1], 190 | }, 191 | { 192 | data: { posts: [post3, post1, post2] }, 193 | name: POSTS, 194 | params: { _sort: '-views,id' }, 195 | res: [post3, post2, post1], 196 | }, 197 | { 198 | name: POSTS, 199 | params: { published: 'true' }, 200 | res: [post1], 201 | }, 202 | { 203 | name: POSTS, 204 | params: { published: 'false' }, 205 | res: [post2, post3], 206 | }, 207 | { 208 | name: POSTS, 209 | params: { views_lt: post3.views.toString(), published: 'false' }, 210 | res: [post2], 211 | }, 212 | { 213 | name: POSTS, 214 | params: { _start: 0, _end: 2 }, 215 | res: [post1, post2], 216 | }, 217 | { 218 | name: POSTS, 219 | params: { _start: 1, _end: 3 }, 220 | res: [post2, post3], 221 | }, 222 | { 223 | name: POSTS, 224 | params: { _start: 0, _limit: 2 }, 225 | res: [post1, post2], 226 | }, 227 | { 228 | name: POSTS, 229 | params: { _start: 1, _limit: 2 }, 230 | res: [post2, post3], 231 | }, 232 | { 233 | name: POSTS, 234 | params: { _page: 1, _per_page: 2 }, 235 | res: { 236 | first: 1, 237 | last: 2, 238 | prev: null, 239 | next: 2, 240 | pages: 2, 241 | items, 242 | data: [post1, post2], 243 | }, 244 | }, 245 | { 246 | name: POSTS, 247 | params: { _page: 2, _per_page: 2 }, 248 | res: { 249 | first: 1, 250 | last: 2, 251 | prev: 1, 252 | next: null, 253 | pages: 2, 254 | items, 255 | data: [post3], 256 | }, 257 | }, 258 | { 259 | name: POSTS, 260 | params: { _page: 3, _per_page: 2 }, 261 | res: { 262 | first: 1, 263 | last: 2, 264 | prev: 1, 265 | next: null, 266 | pages: 2, 267 | items, 268 | data: [post3], 269 | }, 270 | }, 271 | { 272 | name: POSTS, 273 | params: { _page: 2, _per_page: 1 }, 274 | res: { 275 | first: 1, 276 | last: 3, 277 | prev: 1, 278 | next: 3, 279 | pages: 3, 280 | items, 281 | data: [post2], 282 | }, 283 | }, 284 | { 285 | name: POSTS, 286 | params: { _embed: ['comments'] }, 287 | res: [ 288 | { ...post1, comments: [comment1] }, 289 | { ...post2, comments: [] }, 290 | { ...post3, comments: [] }, 291 | ], 292 | }, 293 | { 294 | name: COMMENTS, 295 | params: { _embed: ['post'] }, 296 | res: [{ ...comment1, post: post1 }], 297 | }, 298 | { 299 | name: UNKNOWN_RESOURCE, 300 | res: undefined, 301 | }, 302 | { 303 | name: OBJECT, 304 | res: obj, 305 | }, 306 | ] 307 | for (const tc of arr) { 308 | await t.test(`${tc.name} ${JSON.stringify(tc.params)}`, () => { 309 | if (tc.data) { 310 | db.data = tc.data 311 | } else { 312 | reset() 313 | } 314 | 315 | assert.deepEqual(service.find(tc.name, tc.params), tc.res) 316 | }) 317 | } 318 | }) 319 | 320 | await test('create', async () => { 321 | reset() 322 | const post = { title: 'new post' } 323 | const res = await service.create(POSTS, post) 324 | assert.equal(res?.['title'], post.title) 325 | assert.equal(typeof res?.['id'], 'string', 'id should be a string') 326 | 327 | assert.equal(await service.create(UNKNOWN_RESOURCE, post), undefined) 328 | }) 329 | 330 | await test('update', async () => { 331 | reset() 332 | const obj = { f1: 'bar' } 333 | const res = await service.update(OBJECT, obj) 334 | assert.equal(res, obj) 335 | 336 | assert.equal( 337 | await service.update(UNKNOWN_RESOURCE, obj), 338 | undefined, 339 | 'should ignore unknown resources', 340 | ) 341 | assert.equal( 342 | await service.update(POSTS, {}), 343 | undefined, 344 | 'should ignore arrays', 345 | ) 346 | }) 347 | 348 | await test('updateById', async () => { 349 | reset() 350 | const post = { id: 'xxx', title: 'updated post' } 351 | const res = await service.updateById(POSTS, post1.id, post) 352 | assert.equal(res?.['id'], post1.id, 'id should not change') 353 | assert.equal(res?.['title'], post.title) 354 | 355 | assert.equal( 356 | await service.updateById(UNKNOWN_RESOURCE, post1.id, post), 357 | undefined, 358 | ) 359 | assert.equal(await service.updateById(POSTS, UNKNOWN_ID, post), undefined) 360 | }) 361 | 362 | await test('patchById', async () => { 363 | reset() 364 | const post = { id: 'xxx', title: 'updated post' } 365 | const res = await service.patchById(POSTS, post1.id, post) 366 | assert.notEqual(res, undefined) 367 | assert.equal(res?.['id'], post1.id) 368 | assert.equal(res?.['title'], post.title) 369 | 370 | assert.equal( 371 | await service.patchById(UNKNOWN_RESOURCE, post1.id, post), 372 | undefined, 373 | ) 374 | assert.equal(await service.patchById(POSTS, UNKNOWN_ID, post), undefined) 375 | }) 376 | 377 | await test('destroy', async () => { 378 | reset() 379 | let prevLength = Number(db.data?.[POSTS]?.length) || 0 380 | await service.destroyById(POSTS, post1.id) 381 | assert.equal(db.data?.[POSTS]?.length, prevLength - 1) 382 | assert.deepEqual(db.data?.[COMMENTS], [{ ...comment1, postId: null }]) 383 | 384 | reset() 385 | prevLength = db.data?.[POSTS]?.length || 0 386 | await service.destroyById(POSTS, post1.id, [COMMENTS]) 387 | assert.equal(db.data[POSTS].length, prevLength - 1) 388 | assert.equal(db.data[COMMENTS].length, 0) 389 | 390 | assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined) 391 | assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined) 392 | }) 393 | -------------------------------------------------------------------------------- /src/service.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'node:crypto' 2 | 3 | import { getProperty } from 'dot-prop' 4 | import inflection from 'inflection' 5 | import { Low } from 'lowdb' 6 | import sortOn from 'sort-on' 7 | 8 | export type Item = Record 9 | 10 | export type Data = Record 11 | 12 | export function isItem(obj: unknown): obj is Item { 13 | return typeof obj === 'object' && obj !== null 14 | } 15 | 16 | export function isData(obj: unknown): obj is Record { 17 | if (typeof obj !== 'object' || obj === null) { 18 | return false 19 | } 20 | 21 | const data = obj as Record 22 | return Object.values(data).every( 23 | (value) => Array.isArray(value) && value.every(isItem), 24 | ) 25 | } 26 | 27 | enum Condition { 28 | lt = 'lt', 29 | lte = 'lte', 30 | gt = 'gt', 31 | gte = 'gte', 32 | ne = 'ne', 33 | default = '', 34 | } 35 | 36 | function isCondition(value: string): value is Condition { 37 | return Object.values(Condition).includes(value) 38 | } 39 | 40 | export type PaginatedItems = { 41 | first: number 42 | prev: number | null 43 | next: number | null 44 | last: number 45 | pages: number 46 | items: number 47 | data: Item[] 48 | } 49 | 50 | function ensureArray(arg: string | string[] = []): string[] { 51 | return Array.isArray(arg) ? arg : [arg] 52 | } 53 | 54 | function embed(db: Low, name: string, item: Item, related: string): Item { 55 | if (inflection.singularize(related) === related) { 56 | const relatedData = db.data[inflection.pluralize(related)] as Item[] 57 | if (!relatedData) { 58 | return item 59 | } 60 | const foreignKey = `${related}Id` 61 | const relatedItem = relatedData.find((relatedItem: Item) => { 62 | return relatedItem['id'] === item[foreignKey] 63 | }) 64 | return { ...item, [related]: relatedItem } 65 | } 66 | const relatedData: Item[] = db.data[related] as Item[] 67 | 68 | if (!relatedData) { 69 | return item 70 | } 71 | 72 | const foreignKey = `${inflection.singularize(name)}Id` 73 | const relatedItems = relatedData.filter( 74 | (relatedItem: Item) => relatedItem[foreignKey] === item['id'], 75 | ) 76 | 77 | return { ...item, [related]: relatedItems } 78 | } 79 | 80 | function nullifyForeignKey(db: Low, name: string, id: string) { 81 | const foreignKey = `${inflection.singularize(name)}Id` 82 | 83 | Object.entries(db.data).forEach(([key, items]) => { 84 | // Skip 85 | if (key === name) return 86 | 87 | // Nullify 88 | if (Array.isArray(items)) { 89 | items.forEach((item) => { 90 | if (item[foreignKey] === id) { 91 | item[foreignKey] = null 92 | } 93 | }) 94 | } 95 | }) 96 | } 97 | 98 | function deleteDependents(db: Low, name: string, dependents: string[]) { 99 | const foreignKey = `${inflection.singularize(name)}Id` 100 | 101 | Object.entries(db.data).forEach(([key, items]) => { 102 | // Skip 103 | if (key === name || !dependents.includes(key)) return 104 | 105 | // Delete if foreign key is null 106 | if (Array.isArray(items)) { 107 | db.data[key] = items.filter((item) => item[foreignKey] !== null) 108 | } 109 | }) 110 | } 111 | 112 | function randomId(): string { 113 | return randomBytes(2).toString('hex') 114 | } 115 | 116 | function fixItemsIds(items: Item[]) { 117 | items.forEach((item) => { 118 | if (typeof item['id'] === 'number') { 119 | item['id'] = item['id'].toString() 120 | } 121 | if (item['id'] === undefined) { 122 | item['id'] = randomId() 123 | } 124 | }) 125 | } 126 | 127 | // Ensure all items have an id 128 | function fixAllItemsIds(data: Data) { 129 | Object.values(data).forEach((value) => { 130 | if (Array.isArray(value)) { 131 | fixItemsIds(value) 132 | } 133 | }) 134 | } 135 | 136 | export class Service { 137 | #db: Low 138 | 139 | constructor(db: Low) { 140 | fixAllItemsIds(db.data) 141 | this.#db = db 142 | } 143 | 144 | #get(name: string): Item[] | Item | undefined { 145 | return this.#db.data[name] 146 | } 147 | 148 | has(name: string): boolean { 149 | return Object.prototype.hasOwnProperty.call(this.#db?.data, name) 150 | } 151 | 152 | findById( 153 | name: string, 154 | id: string, 155 | query: { _embed?: string[] | string }, 156 | ): Item | undefined { 157 | const value = this.#get(name) 158 | 159 | if (Array.isArray(value)) { 160 | let item = value.find((item) => item['id'] === id) 161 | ensureArray(query._embed).forEach((related) => { 162 | if (item !== undefined) item = embed(this.#db, name, item, related) 163 | }) 164 | return item 165 | } 166 | 167 | return 168 | } 169 | 170 | find( 171 | name: string, 172 | query: { 173 | [key: string]: unknown 174 | _embed?: string | string[] 175 | _sort?: string 176 | _start?: number 177 | _end?: number 178 | _limit?: number 179 | _page?: number 180 | _per_page?: number 181 | } = {}, 182 | ): Item[] | PaginatedItems | Item | undefined { 183 | let items = this.#get(name) 184 | 185 | if (!Array.isArray(items)) { 186 | return items 187 | } 188 | 189 | // Include 190 | ensureArray(query._embed).forEach((related) => { 191 | if (items !== undefined && Array.isArray(items)) { 192 | items = items.map((item) => embed(this.#db, name, item, related)) 193 | } 194 | }) 195 | 196 | // Return list if no query params 197 | if (Object.keys(query).length === 0) { 198 | return items 199 | } 200 | 201 | // Convert query params to conditions 202 | const conds: [string, Condition, string | string[]][] = [] 203 | for (const [key, value] of Object.entries(query)) { 204 | if (value === undefined || typeof value !== 'string') { 205 | continue 206 | } 207 | const re = /_(lt|lte|gt|gte|ne)$/ 208 | const reArr = re.exec(key) 209 | const op = reArr?.at(1) 210 | if (op && isCondition(op)) { 211 | const field = key.replace(re, '') 212 | conds.push([field, op, value]) 213 | continue 214 | } 215 | if ( 216 | [ 217 | '_embed', 218 | '_sort', 219 | '_start', 220 | '_end', 221 | '_limit', 222 | '_page', 223 | '_per_page', 224 | ].includes(key) 225 | ) { 226 | continue 227 | } 228 | conds.push([key, Condition.default, value]) 229 | } 230 | 231 | // Loop through conditions and filter items 232 | let filtered = items 233 | for (const [key, op, paramValue] of conds) { 234 | filtered = filtered.filter((item: Item) => { 235 | if (paramValue && !Array.isArray(paramValue)) { 236 | // https://github.com/sindresorhus/dot-prop/issues/95 237 | const itemValue: unknown = getProperty(item, key) 238 | switch (op) { 239 | // item_gt=value 240 | case Condition.gt: { 241 | if ( 242 | !( 243 | typeof itemValue === 'number' && 244 | itemValue > parseInt(paramValue) 245 | ) 246 | ) { 247 | return false 248 | } 249 | break 250 | } 251 | // item_gte=value 252 | case Condition.gte: { 253 | if ( 254 | !( 255 | typeof itemValue === 'number' && 256 | itemValue >= parseInt(paramValue) 257 | ) 258 | ) { 259 | return false 260 | } 261 | break 262 | } 263 | // item_lt=value 264 | case Condition.lt: { 265 | if ( 266 | !( 267 | typeof itemValue === 'number' && 268 | itemValue < parseInt(paramValue) 269 | ) 270 | ) { 271 | return false 272 | } 273 | break 274 | } 275 | // item_lte=value 276 | case Condition.lte: { 277 | if ( 278 | !( 279 | typeof itemValue === 'number' && 280 | itemValue <= parseInt(paramValue) 281 | ) 282 | ) { 283 | return false 284 | } 285 | break 286 | } 287 | // item_ne=value 288 | case Condition.ne: { 289 | switch (typeof itemValue) { 290 | case 'number': 291 | return itemValue !== parseInt(paramValue) 292 | case 'string': 293 | return itemValue !== paramValue 294 | case 'boolean': 295 | return itemValue !== (paramValue === 'true') 296 | } 297 | break 298 | } 299 | // item=value 300 | case Condition.default: { 301 | switch (typeof itemValue) { 302 | case 'number': 303 | return itemValue === parseInt(paramValue) 304 | case 'string': 305 | return itemValue === paramValue 306 | case 'boolean': 307 | return itemValue === (paramValue === 'true') 308 | case 'undefined': 309 | return false 310 | } 311 | } 312 | } 313 | } 314 | return true 315 | }) 316 | } 317 | 318 | // Sort 319 | const sort = query._sort || '' 320 | const sorted = sortOn(filtered, sort.split(',')) 321 | 322 | // Slice 323 | const start = query._start 324 | const end = query._end 325 | const limit = query._limit 326 | if (start !== undefined) { 327 | if (end !== undefined) { 328 | return sorted.slice(start, end) 329 | } 330 | return sorted.slice(start, start + (limit || 0)) 331 | } 332 | if (limit !== undefined) { 333 | return sorted.slice(0, limit) 334 | } 335 | 336 | // Paginate 337 | let page = query._page 338 | const perPage = query._per_page || 10 339 | if (page) { 340 | const items = sorted.length 341 | const pages = Math.ceil(items / perPage) 342 | 343 | // Ensure page is within the valid range 344 | page = Math.max(1, Math.min(page, pages)) 345 | 346 | const first = 1 347 | const prev = page > 1 ? page - 1 : null 348 | const next = page < pages ? page + 1 : null 349 | const last = pages 350 | 351 | const start = (page - 1) * perPage 352 | const end = start + perPage 353 | const data = sorted.slice(start, end) 354 | 355 | return { 356 | first, 357 | prev, 358 | next, 359 | last, 360 | pages, 361 | items, 362 | data, 363 | } 364 | } 365 | 366 | return sorted.slice(start, end) 367 | } 368 | 369 | async create( 370 | name: string, 371 | data: Omit = {}, 372 | ): Promise { 373 | const items = this.#get(name) 374 | if (items === undefined || !Array.isArray(items)) return 375 | 376 | const item = { id: randomId(), ...data } 377 | items.push(item) 378 | 379 | await this.#db.write() 380 | return item 381 | } 382 | 383 | async #updateOrPatch( 384 | name: string, 385 | body: Item = {}, 386 | isPatch: boolean, 387 | ): Promise { 388 | const item = this.#get(name) 389 | if (item === undefined || Array.isArray(item)) return 390 | 391 | const nextItem = (this.#db.data[name] = isPatch ? { item, ...body } : body) 392 | 393 | await this.#db.write() 394 | return nextItem 395 | } 396 | 397 | async #updateOrPatchById( 398 | name: string, 399 | id: string, 400 | body: Item = {}, 401 | isPatch: boolean, 402 | ): Promise { 403 | const items = this.#get(name) 404 | if (items === undefined || !Array.isArray(items)) return 405 | 406 | const item = items.find((item) => item['id'] === id) 407 | if (!item) return 408 | 409 | const nextItem = isPatch ? { ...item, ...body, id } : { ...body, id } 410 | const index = items.indexOf(item) 411 | items.splice(index, 1, nextItem) 412 | 413 | await this.#db.write() 414 | return nextItem 415 | } 416 | 417 | async update(name: string, body: Item = {}): Promise { 418 | return this.#updateOrPatch(name, body, false) 419 | } 420 | 421 | async patch(name: string, body: Item = {}): Promise { 422 | return this.#updateOrPatch(name, body, true) 423 | } 424 | 425 | async updateById( 426 | name: string, 427 | id: string, 428 | body: Item = {}, 429 | ): Promise { 430 | return this.#updateOrPatchById(name, id, body, false) 431 | } 432 | 433 | async patchById( 434 | name: string, 435 | id: string, 436 | body: Item = {}, 437 | ): Promise { 438 | return this.#updateOrPatchById(name, id, body, true) 439 | } 440 | 441 | async destroyById( 442 | name: string, 443 | id: string, 444 | dependent?: string | string[], 445 | ): Promise { 446 | const items = this.#get(name) 447 | if (items === undefined || !Array.isArray(items)) return 448 | 449 | const item = items.find((item) => item['id'] === id) 450 | if (item === undefined) return 451 | const index = items.indexOf(item) 452 | items.splice(index, 1) 453 | 454 | nullifyForeignKey(this.#db, name, id) 455 | const dependents = ensureArray(dependent) 456 | deleteDependents(this.#db, name, dependents) 457 | 458 | await this.#db.write() 459 | return item 460 | } 461 | } 462 | --------------------------------------------------------------------------------