json-server 0.17.4 → 1.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/app.js ADDED
@@ -0,0 +1,75 @@
1
+ import { dirname, isAbsolute, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { App } from '@tinyhttp/app';
4
+ import { Eta } from 'eta';
5
+ import { json } from 'milliparsec';
6
+ import sirv from 'sirv';
7
+ import { isItem, Service } from './service.js';
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const isProduction = process.env['NODE_ENV'] === 'production';
10
+ const eta = new Eta({
11
+ views: join(__dirname, '../views'),
12
+ cache: isProduction,
13
+ });
14
+ export function createApp(db, options = {}) {
15
+ // Create service
16
+ const service = new Service(db);
17
+ // Create app
18
+ const app = new App();
19
+ // Body parser
20
+ app.use(json());
21
+ // Static files
22
+ app.use(sirv(join(__dirname, '../public')));
23
+ options.static
24
+ ?.map((path) => (isAbsolute(path) ? path : join(process.cwd(), path)))
25
+ .forEach((dir) => app.use(sirv(dir, { dev: !isProduction })));
26
+ app.get('/', (_req, res) => res.send(eta.render('index.html', { data: db.data })));
27
+ app.get('/:name', (req, res, next) => {
28
+ const { name = '' } = req.params;
29
+ res.locals['data'] = service.find(name, req.query);
30
+ next();
31
+ });
32
+ app.get('/:name/:id', (req, res, next) => {
33
+ const { name = '', id = '' } = req.params;
34
+ res.locals['data'] = service.findById(name, id, req.query);
35
+ next();
36
+ });
37
+ app.post('/:name', async (req, res, next) => {
38
+ const { name = '' } = req.params;
39
+ if (isItem(req.body)) {
40
+ res.locals['data'] = await service.create(name, req.body);
41
+ }
42
+ next();
43
+ });
44
+ app.put('/:name/:id', async (req, res, next) => {
45
+ const { name = '', id = '' } = req.params;
46
+ if (isItem(req.body)) {
47
+ res.locals['data'] = await service.update(name, id, req.body);
48
+ }
49
+ next();
50
+ });
51
+ app.patch('/:name/:id', async (req, res, next) => {
52
+ const { name = '', id = '' } = req.params;
53
+ if (isItem(req.body)) {
54
+ res.locals['data'] = await service.patch(name, id, req.body);
55
+ }
56
+ next();
57
+ });
58
+ app.delete('/:name/:id', async (req, res, next) => {
59
+ const { name = '', id = '' } = req.params;
60
+ res.locals['data'] = await service.destroy(name, id);
61
+ next();
62
+ });
63
+ app.use('/:name', (req, res) => {
64
+ const { data } = res.locals;
65
+ if (data === undefined) {
66
+ res.sendStatus(404);
67
+ }
68
+ else {
69
+ if (req.method === 'POST')
70
+ res.status(201);
71
+ res.json(data);
72
+ }
73
+ });
74
+ return app;
75
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
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
+ import getPort from 'get-port';
6
+ import { Low, Memory } from 'lowdb';
7
+ import { temporaryDirectory } from 'tempy';
8
+ import { createApp } from './app.js';
9
+ const port = await getPort();
10
+ // Create custom static dir with an html file
11
+ const tmpDir = temporaryDirectory();
12
+ const file = 'file.html';
13
+ writeFileSync(join(tmpDir, file), 'utf-8');
14
+ // Create app
15
+ const db = new Low(new Memory(), {});
16
+ db.data = {
17
+ posts: [{ id: '1', title: 'foo' }],
18
+ comments: [{ id: '1', postId: '1' }],
19
+ };
20
+ const app = createApp(db, { static: [tmpDir] });
21
+ await new Promise((resolve, reject) => {
22
+ try {
23
+ const server = app.listen(port, () => resolve());
24
+ test.after(() => server.close());
25
+ }
26
+ catch (err) {
27
+ reject(err);
28
+ }
29
+ });
30
+ await test('createApp', async () => {
31
+ // URLs
32
+ const POSTS = '/posts';
33
+ const POST_1 = '/posts/1';
34
+ const POST_NOT_FOUND = '/posts/-1';
35
+ const COMMENTS = '/comments';
36
+ const POST_COMMENTS = '/comments?postId=1';
37
+ const NOT_FOUND = '/not-found';
38
+ const arr = [
39
+ // Static
40
+ { method: 'GET', url: '/', statusCode: 200 },
41
+ { method: 'GET', url: '/output.css', statusCode: 200 },
42
+ { method: 'GET', url: `/${file}`, statusCode: 200 },
43
+ // API
44
+ { method: 'GET', url: POSTS, statusCode: 200 },
45
+ { method: 'GET', url: POST_1, statusCode: 200 },
46
+ { method: 'GET', url: POST_NOT_FOUND, statusCode: 404 },
47
+ { method: 'GET', url: COMMENTS, statusCode: 200 },
48
+ { method: 'GET', url: POST_COMMENTS, statusCode: 200 },
49
+ { method: 'GET', url: NOT_FOUND, statusCode: 404 },
50
+ { method: 'POST', url: POSTS, statusCode: 201 },
51
+ { method: 'POST', url: POST_1, statusCode: 404 },
52
+ { method: 'POST', url: POST_NOT_FOUND, statusCode: 404 },
53
+ { method: 'POST', url: NOT_FOUND, statusCode: 404 },
54
+ { method: 'PUT', url: POSTS, statusCode: 404 },
55
+ { method: 'PUT', url: POST_1, statusCode: 200 },
56
+ { method: 'PUT', url: POST_NOT_FOUND, statusCode: 404 },
57
+ { method: 'PUT', url: NOT_FOUND, statusCode: 404 },
58
+ { method: 'PATCH', url: POSTS, statusCode: 404 },
59
+ { method: 'PATCH', url: POST_1, statusCode: 200 },
60
+ { method: 'PATCH', url: POST_NOT_FOUND, statusCode: 404 },
61
+ { method: 'PATCH', url: NOT_FOUND, statusCode: 404 },
62
+ { method: 'DELETE', url: POSTS, statusCode: 404 },
63
+ { method: 'DELETE', url: POST_1, statusCode: 200 },
64
+ { method: 'DELETE', url: POST_NOT_FOUND, statusCode: 404 },
65
+ { method: 'DELETE', url: NOT_FOUND, statusCode: 404 },
66
+ ];
67
+ for (const tc of arr) {
68
+ const response = await fetch(`http://localhost:${port}${tc.url}`, {
69
+ method: tc.method,
70
+ });
71
+ assert.equal(response.status, tc.statusCode, `${response.status} !== ${tc.statusCode} ${tc.method} ${tc.url} failed`);
72
+ }
73
+ });
package/lib/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/lib/bin.js ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { watch } from 'chokidar';
6
+ import { Low } from 'lowdb';
7
+ import { JSONFile } from 'lowdb/node';
8
+ import { createApp } from './app.js';
9
+ import { Observer } from './observer.js';
10
+ // Parse args
11
+ const { values, positionals } = parseArgs({
12
+ args: process.argv.slice(2),
13
+ options: {
14
+ port: {
15
+ type: 'string',
16
+ short: 'p',
17
+ },
18
+ host: {
19
+ type: 'string',
20
+ short: 'h',
21
+ },
22
+ static: {
23
+ type: 'string',
24
+ short: 's',
25
+ multiple: true,
26
+ },
27
+ help: {
28
+ type: 'boolean',
29
+ },
30
+ version: {
31
+ type: 'boolean',
32
+ },
33
+ },
34
+ allowPositionals: true,
35
+ });
36
+ if (values.help || positionals.length === 0) {
37
+ console.log(`Usage: json-server [options] [file]
38
+ Options:
39
+ -p, --port <port> Port (default: 3000)
40
+ -h, --host <host> Host (default: localhost)
41
+ -s, --static <dir> Static files directory (multiple allowed)
42
+ --help Show this message
43
+ `);
44
+ }
45
+ if (values.version) {
46
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
47
+ console.log(pkg.version);
48
+ process.exit();
49
+ }
50
+ // App args and options
51
+ const file = positionals[0] ?? 'db.json';
52
+ const port = parseInt(values.port ?? process.env['PORT'] ?? '3000');
53
+ const host = values.host ?? process.env['HOST'] ?? 'localhost';
54
+ // Set up database
55
+ const adapter = new JSONFile(file);
56
+ const observer = new Observer(adapter);
57
+ const db = new Low(observer, {});
58
+ await db.read();
59
+ // Create app
60
+ const app = createApp(db, { logger: false, static: values.static });
61
+ function routes(db) {
62
+ return Object.keys(db.data).map((key) => `http://${host}:${port}/${key}`);
63
+ }
64
+ // Watch file for changes
65
+ if (process.env['NODE_ENV'] !== 'production') {
66
+ let writing = false; // true if the file is being written to by the app
67
+ observer.onWriteStart = () => {
68
+ writing = true;
69
+ };
70
+ observer.onWriteEnd = () => {
71
+ writing = false;
72
+ };
73
+ observer.onReadStart = () => console.log(`reloading ${file}...`);
74
+ observer.onReadEnd = () => console.log('reloaded');
75
+ watch(file).on('change', () => {
76
+ // Do no reload if the file is being written to by the app
77
+ if (!writing) {
78
+ db.read()
79
+ .then(() => routes(db))
80
+ .catch((e) => {
81
+ if (e instanceof SyntaxError) {
82
+ return console.log(e.message);
83
+ }
84
+ console.log(e);
85
+ });
86
+ }
87
+ });
88
+ }
89
+ app.listen(port, () => {
90
+ console.log(`Started on :${port}`);
91
+ console.log(routes(db));
92
+ });
@@ -0,0 +1,11 @@
1
+ import { Adapter } from 'lowdb';
2
+ export declare class Observer<T> {
3
+ #private;
4
+ onReadStart: () => void;
5
+ onReadEnd: () => void;
6
+ onWriteStart: () => void;
7
+ onWriteEnd: () => void;
8
+ constructor(adapter: Adapter<T>);
9
+ read(): Promise<T | null>;
10
+ write(arg: T): Promise<void>;
11
+ }
@@ -0,0 +1,30 @@
1
+ // Lowdb adapter to observe read/write events
2
+ export class Observer {
3
+ #adapter;
4
+ onReadStart = function () {
5
+ return;
6
+ };
7
+ onReadEnd = function () {
8
+ return;
9
+ };
10
+ onWriteStart = function () {
11
+ return;
12
+ };
13
+ onWriteEnd = function () {
14
+ return;
15
+ };
16
+ constructor(adapter) {
17
+ this.#adapter = adapter;
18
+ }
19
+ async read() {
20
+ this.onReadStart();
21
+ const data = await this.#adapter.read();
22
+ this.onReadEnd();
23
+ return data;
24
+ }
25
+ async write(arg) {
26
+ this.onWriteStart();
27
+ await this.#adapter.write(arg);
28
+ this.onWriteEnd();
29
+ }
30
+ }
@@ -0,0 +1,39 @@
1
+ import { Low } from 'lowdb';
2
+ export type Headers = Record<string, string>;
3
+ export type Item = Record<string, unknown>;
4
+ export type Data = Record<string, Item[]>;
5
+ export declare function isItem(obj: unknown): obj is Item;
6
+ export declare function isData(obj: unknown): obj is Record<string, Item[]>;
7
+ export type PaginatedItems = {
8
+ first: number;
9
+ prev: number | null;
10
+ next: number | null;
11
+ last: number;
12
+ pages: number;
13
+ items: number;
14
+ data: Item[];
15
+ };
16
+ export declare class Service {
17
+ #private;
18
+ constructor(db: Low<Data>);
19
+ list(): string[];
20
+ has(name: string): boolean;
21
+ findById(name: string, id: string, query: {
22
+ _include?: string[];
23
+ }): Item | undefined;
24
+ find(name: string, query?: {
25
+ [key: string]: unknown;
26
+ } & {
27
+ _include?: string[];
28
+ _sort?: string;
29
+ _start?: number;
30
+ _end?: number;
31
+ _limit?: number;
32
+ _page?: number;
33
+ _per_page?: number;
34
+ }): Item[] | PaginatedItems | undefined;
35
+ create(name: string, data?: Omit<Item, 'id'>): Promise<Item | undefined>;
36
+ update(name: string, id: string, body?: Omit<Item, 'id'>): Promise<Item | undefined>;
37
+ patch(name: string, id: string, body?: Omit<Item, 'id'>): Promise<Item | undefined>;
38
+ destroy(name: string, id: string, dependents?: string[]): Promise<Item | undefined>;
39
+ }
package/lib/service.js ADDED
@@ -0,0 +1,270 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { getProperty } from 'dot-prop';
3
+ import inflection from 'inflection';
4
+ import sortOn from 'sort-on';
5
+ export function isItem(obj) {
6
+ return typeof obj === 'object' && obj !== null;
7
+ }
8
+ export function isData(obj) {
9
+ if (typeof obj !== 'object' || obj === null) {
10
+ return false;
11
+ }
12
+ const data = obj;
13
+ return Object.values(data).every((value) => Array.isArray(value) && value.every(isItem));
14
+ }
15
+ var Operator;
16
+ (function (Operator) {
17
+ Operator["lt"] = "lt";
18
+ Operator["lte"] = "lte";
19
+ Operator["gt"] = "gt";
20
+ Operator["gte"] = "gte";
21
+ Operator["ne"] = "ne";
22
+ Operator["default"] = "";
23
+ })(Operator || (Operator = {}));
24
+ function isOperator(value) {
25
+ return Object.values(Operator).includes(value);
26
+ }
27
+ function include(db, name, item, related) {
28
+ if (inflection.singularize(related) === related) {
29
+ const relatedData = db.data[inflection.pluralize(related)];
30
+ if (!relatedData) {
31
+ return item;
32
+ }
33
+ const foreignKey = `${related}Id`;
34
+ const relatedItem = relatedData.find((relatedItem) => {
35
+ return relatedItem['id'] === item[foreignKey];
36
+ });
37
+ return { ...item, [related]: relatedItem };
38
+ }
39
+ const relatedData = db.data[related];
40
+ if (!relatedData) {
41
+ return item;
42
+ }
43
+ const foreignKey = `${inflection.singularize(name)}Id`;
44
+ const relatedItems = relatedData.filter((relatedItem) => relatedItem[foreignKey] === item['id']);
45
+ return { ...item, [related]: relatedItems };
46
+ }
47
+ function nullifyForeignKey(db, name, id) {
48
+ const foreignKey = `${inflection.singularize(name)}Id`;
49
+ Object.entries(db.data).forEach(([key, items]) => {
50
+ // Skip
51
+ if (key === name)
52
+ return;
53
+ // Nullify
54
+ items.forEach((item) => {
55
+ if (item[foreignKey] === id) {
56
+ item[foreignKey] = null;
57
+ }
58
+ });
59
+ });
60
+ }
61
+ function deleteDependents(db, name, dependents) {
62
+ const foreignKey = `${inflection.singularize(name)}Id`;
63
+ Object.entries(db.data).forEach(([key, items]) => {
64
+ // Skip
65
+ if (key === name || !dependents.includes(key))
66
+ return;
67
+ // Delete if foreign key is null
68
+ db.data[key] = items.filter((item) => item[foreignKey] !== null);
69
+ });
70
+ }
71
+ export class Service {
72
+ #db;
73
+ constructor(db) {
74
+ this.#db = db;
75
+ }
76
+ #get(name) {
77
+ return this.#db.data[name];
78
+ }
79
+ list() {
80
+ return Object.keys(this.#db?.data || {});
81
+ }
82
+ has(name) {
83
+ return Object.prototype.hasOwnProperty.call(this.#db?.data, name);
84
+ }
85
+ findById(name, id, query) {
86
+ let item = this.#get(name)?.find((item) => item['id'] === id);
87
+ query._include?.forEach((related) => {
88
+ if (item !== undefined)
89
+ item = include(this.#db, name, item, related);
90
+ });
91
+ return item;
92
+ }
93
+ find(name, query = {}) {
94
+ let items = this.#get(name);
95
+ // Not found
96
+ if (items === undefined)
97
+ return;
98
+ // Include
99
+ query._include?.forEach((related) => {
100
+ if (items !== undefined)
101
+ items = items.map((item) => include(this.#db, name, item, related));
102
+ });
103
+ // Return list if no query params
104
+ if (Object.keys(query).length === 0) {
105
+ return items;
106
+ }
107
+ // Convert query params to conditions
108
+ const conds = {};
109
+ for (const [key, value] of Object.entries(query)) {
110
+ if (value === undefined || typeof value !== 'string') {
111
+ continue;
112
+ }
113
+ const re = /_(lt|lte|gt|gte|ne)$/;
114
+ const reArr = re.exec(key);
115
+ const op = reArr?.at(1);
116
+ if (op && isOperator(op)) {
117
+ const field = key.replace(re, '');
118
+ conds[field] = [op, value];
119
+ continue;
120
+ }
121
+ if (['_sort', '_start', '_end', '_limit', '_page', '_per_page'].includes(key))
122
+ continue;
123
+ conds[key] = [Operator.default, value];
124
+ }
125
+ // Loop through conditions and filter items
126
+ const res = items.filter((item) => {
127
+ for (const [key, [op, paramValue]] of Object.entries(conds)) {
128
+ if (paramValue && !Array.isArray(paramValue)) {
129
+ const itemValue = getProperty(item, key);
130
+ switch (op) {
131
+ // item_gt=value
132
+ case Operator.gt: {
133
+ if (!(typeof itemValue === 'number' &&
134
+ itemValue > parseInt(paramValue))) {
135
+ return false;
136
+ }
137
+ break;
138
+ }
139
+ // item_gte=value
140
+ case Operator.gte: {
141
+ if (!(typeof itemValue === 'number' &&
142
+ itemValue >= parseInt(paramValue))) {
143
+ return false;
144
+ }
145
+ break;
146
+ }
147
+ // item_lt=value
148
+ case Operator.lt: {
149
+ if (!(typeof itemValue === 'number' &&
150
+ itemValue < parseInt(paramValue))) {
151
+ return false;
152
+ }
153
+ break;
154
+ }
155
+ // item_lte=value
156
+ case Operator.lte: {
157
+ if (!(typeof itemValue === 'number' &&
158
+ itemValue <= parseInt(paramValue))) {
159
+ return false;
160
+ }
161
+ break;
162
+ }
163
+ // item_ne=value
164
+ case Operator.ne: {
165
+ if (!(itemValue != paramValue))
166
+ return false;
167
+ break;
168
+ }
169
+ // item=value
170
+ case Operator.default: {
171
+ if (!(itemValue == paramValue))
172
+ return false;
173
+ }
174
+ }
175
+ }
176
+ }
177
+ return true;
178
+ });
179
+ const sort = query._sort || '';
180
+ const sorted = sortOn(res, sort.split(','));
181
+ const start = query._start;
182
+ const end = query._end;
183
+ const limit = query._limit;
184
+ if (start === undefined && limit) {
185
+ return sorted.slice(0, limit);
186
+ }
187
+ if (start && limit) {
188
+ return sorted.slice(start, start + limit);
189
+ }
190
+ let page = query._page;
191
+ const perPage = query._per_page || 10;
192
+ if (page) {
193
+ const items = sorted.length;
194
+ const pages = Math.ceil(items / perPage);
195
+ // Ensure page is within the valid range
196
+ page = Math.max(1, Math.min(page, pages));
197
+ const first = 1;
198
+ const prev = page > 1 ? page - 1 : null;
199
+ const next = page < pages ? page + 1 : null;
200
+ const last = pages;
201
+ const start = (page - 1) * perPage;
202
+ const end = start + perPage;
203
+ const data = sorted.slice(start, end);
204
+ return {
205
+ first,
206
+ prev,
207
+ next,
208
+ last,
209
+ pages,
210
+ items,
211
+ data,
212
+ };
213
+ }
214
+ return sorted.slice(start, end);
215
+ }
216
+ async create(name, data = {}) {
217
+ const items = this.#get(name);
218
+ if (items === undefined)
219
+ return;
220
+ const nextData = { id: randomBytes(2).toString('hex'), ...data };
221
+ items.push(nextData);
222
+ await this.#db.write();
223
+ return nextData;
224
+ }
225
+ async update(name, id, body = {}) {
226
+ const items = this.#get(name);
227
+ if (items === undefined)
228
+ return;
229
+ const index = items.findIndex((item) => item['id'] === id);
230
+ if (index === -1)
231
+ return;
232
+ const item = items.at(index);
233
+ if (item) {
234
+ const nextItem = { ...body, id: item['id'] };
235
+ items.splice(index, 1, nextItem);
236
+ await this.#db.write();
237
+ return nextItem;
238
+ }
239
+ return;
240
+ }
241
+ async patch(name, id, body = {}) {
242
+ const items = this.#get(name);
243
+ if (items === undefined)
244
+ return;
245
+ const index = items.findIndex((item) => item['id'] === id);
246
+ if (index === -1)
247
+ return;
248
+ const item = items.at(index);
249
+ if (item) {
250
+ const nextItem = { ...item, ...body, id: item['id'] };
251
+ items.splice(index, 1, nextItem);
252
+ await this.#db.write();
253
+ return nextItem;
254
+ }
255
+ return;
256
+ }
257
+ async destroy(name, id, dependents = []) {
258
+ const items = this.#get(name);
259
+ if (items === undefined)
260
+ return;
261
+ const index = items.findIndex((item) => item['id'] === id);
262
+ if (index === -1)
263
+ return;
264
+ const item = items.splice(index, 1)[0];
265
+ nullifyForeignKey(this.#db, name, id);
266
+ deleteDependents(this.#db, name, dependents);
267
+ await this.#db.write();
268
+ return item;
269
+ }
270
+ }
@@ -0,0 +1 @@
1
+ export {};