@kollors/deep-json-server 0.1.0 → 0.2.0
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/README.md +38 -2
- package/README.ru.md +38 -2
- package/index.js +3 -579
- package/package.json +6 -4
- package/src/cli.js +70 -0
- package/src/openapi.js +258 -0
- package/src/query.js +233 -0
- package/src/relations.js +116 -0
- package/src/server.js +153 -0
- package/src/utils.js +57 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { JSONFilePreset } from 'lowdb/node';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { matchesWhere, paginateItems, parseWhere, sortItems } from './query.js';
|
|
5
|
+
import { embedItem, parseEmbedPaths } from './relations.js';
|
|
6
|
+
import { createHttpError, getResourceNames, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const CORS_HEADERS = {
|
|
9
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
10
|
+
'Access-Control-Allow-Methods': 'DELETE, GET, OPTIONS, PATCH, POST, PUT',
|
|
11
|
+
'Access-Control-Allow-Origin': '*',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const getCollection = (database, resource) => {
|
|
15
|
+
const collection = isSafeKey(resource) ? database.data[resource] : undefined;
|
|
16
|
+
|
|
17
|
+
if (!Array.isArray(collection)) {
|
|
18
|
+
throw createHttpError(404, 'Ресурс не найден');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return collection;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const getRequestBody = (body) => {
|
|
25
|
+
if (!isObject(body)) {
|
|
26
|
+
throw createHttpError(400, 'Тело запроса должно быть JSON-объектом');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return body;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const findItem = (collection, id) => collection.find((item) => isObject(item) && String(item.id) === id);
|
|
33
|
+
|
|
34
|
+
export async function createServer({ databasePath, logger = true } = {}) {
|
|
35
|
+
const database = await JSONFilePreset(resolveDatabasePath(databasePath), {});
|
|
36
|
+
const server = Fastify({ logger });
|
|
37
|
+
|
|
38
|
+
server.addHook('onRequest', async(_request, reply) => {
|
|
39
|
+
Object.entries(CORS_HEADERS).forEach(([header, value]) => reply.header(header, value));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
server.addHook('preHandler', async() => {
|
|
43
|
+
await database.read();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
server.options('/', async(_request, reply) => reply.code(204).send());
|
|
47
|
+
server.options('/*', async(_request, reply) => reply.code(204).send());
|
|
48
|
+
server.get('/', async() => ({ resources: getResourceNames(database.data) }));
|
|
49
|
+
|
|
50
|
+
server.get('/:resource', async(request) => {
|
|
51
|
+
const collection = getCollection(database, request.params.resource);
|
|
52
|
+
const where = parseWhere(request.query);
|
|
53
|
+
const embedPaths = parseEmbedPaths(request.query._embed);
|
|
54
|
+
const embeddedItems = collection.map((item) => embedItem(database, item, request.params.resource, embedPaths));
|
|
55
|
+
const filteredItems = embeddedItems.filter((item) => matchesWhere(item, where));
|
|
56
|
+
const sortedItems = sortItems(filteredItems, request.query._sort);
|
|
57
|
+
const page = request.query._page == null ? undefined : Number(request.query._page);
|
|
58
|
+
|
|
59
|
+
return page == null ? sortedItems : paginateItems(sortedItems, page, Number(request.query._per_page));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
server.get('/:resource/:id', async(request) => {
|
|
63
|
+
const item = findItem(getCollection(database, request.params.resource), request.params.id);
|
|
64
|
+
|
|
65
|
+
if (item == null) {
|
|
66
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return embedItem(database, item, request.params.resource, parseEmbedPaths(request.query._embed));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
server.post('/:resource', async(request, reply) => {
|
|
73
|
+
const collection = getCollection(database, request.params.resource);
|
|
74
|
+
const item = { ...getRequestBody(request.body), id: randomBytes(8).toString('base64url') };
|
|
75
|
+
|
|
76
|
+
collection.push(item);
|
|
77
|
+
await database.write();
|
|
78
|
+
|
|
79
|
+
return reply.code(201).send(item);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
server.put('/:resource/:id', async(request) => {
|
|
83
|
+
const collection = getCollection(database, request.params.resource);
|
|
84
|
+
const currentItem = findItem(collection, request.params.id);
|
|
85
|
+
|
|
86
|
+
if (currentItem == null) {
|
|
87
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const item = { ...getRequestBody(request.body), id: request.params.id };
|
|
91
|
+
|
|
92
|
+
collection.splice(collection.indexOf(currentItem), 1, item);
|
|
93
|
+
await database.write();
|
|
94
|
+
|
|
95
|
+
return item;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
server.patch('/:resource/:id', async(request) => {
|
|
99
|
+
const collection = getCollection(database, request.params.resource);
|
|
100
|
+
const currentItem = findItem(collection, request.params.id);
|
|
101
|
+
|
|
102
|
+
if (currentItem == null) {
|
|
103
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const item = { ...currentItem, ...getRequestBody(request.body), id: request.params.id };
|
|
107
|
+
|
|
108
|
+
collection.splice(collection.indexOf(currentItem), 1, item);
|
|
109
|
+
await database.write();
|
|
110
|
+
|
|
111
|
+
return item;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
server.delete('/:resource/:id', async(request) => {
|
|
115
|
+
const collection = getCollection(database, request.params.resource);
|
|
116
|
+
const currentItem = findItem(collection, request.params.id);
|
|
117
|
+
|
|
118
|
+
if (currentItem == null) {
|
|
119
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
collection.splice(collection.indexOf(currentItem), 1);
|
|
123
|
+
await database.write();
|
|
124
|
+
|
|
125
|
+
return currentItem;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
server.setErrorHandler((error, request, reply) => {
|
|
129
|
+
const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
|
|
130
|
+
|
|
131
|
+
if (statusCode === 500) {
|
|
132
|
+
request.log.error(error);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return reply.code(statusCode).send({ error: error.message });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return server;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function startServer({ databasePath, host = '127.0.0.1', logger = true, port = 4001 } = {}) {
|
|
142
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
143
|
+
throw new Error('Порт должен быть целым числом от 1 до 65535');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const resolvedDatabasePath = resolveDatabasePath(databasePath);
|
|
147
|
+
const server = await createServer({ databasePath: resolvedDatabasePath, logger });
|
|
148
|
+
|
|
149
|
+
await server.listen({ host, port });
|
|
150
|
+
server.log.info({ database: resolvedDatabasePath }, 'Deep JSON Server запущен');
|
|
151
|
+
|
|
152
|
+
return server;
|
|
153
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
4
|
+
|
|
5
|
+
export const createHttpError = (statusCode, message) => {
|
|
6
|
+
const error = new Error(message);
|
|
7
|
+
|
|
8
|
+
error.statusCode = statusCode;
|
|
9
|
+
|
|
10
|
+
return error;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const getResourceNames = (data) => Object.entries(data).filter(([, value]) => Array.isArray(value)).map(([resource]) => resource);
|
|
14
|
+
export const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
export const isSafeKey = (key) => !UNSAFE_KEYS.has(key);
|
|
16
|
+
export const toArray = (value) => (Array.isArray(value) ? value : [value]);
|
|
17
|
+
|
|
18
|
+
export const isEqual = (left, right) => {
|
|
19
|
+
if (Object.is(left, right)) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
24
|
+
return left.length === right.length && left.every((value, index) => isEqual(value, right[index]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (isObject(left) && isObject(right)) {
|
|
28
|
+
const leftKeys = Object.keys(left);
|
|
29
|
+
const rightKeys = Object.keys(right);
|
|
30
|
+
|
|
31
|
+
return leftKeys.length === rightKeys.length && leftKeys.every((key) => isSafeKey(key) && Object.hasOwn(right, key) && isEqual(left[key], right[key]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const resolveDatabasePath = (databasePath) => {
|
|
38
|
+
if (typeof databasePath !== 'string' || databasePath === '') {
|
|
39
|
+
throw new Error('Укажите путь к JSON-базе данных');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return resolve(databasePath);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const singularize = (value) => {
|
|
46
|
+
if (value === 'clothes' || value === 'movies') {
|
|
47
|
+
return value === 'movies' ? 'movie' : value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (value.endsWith('ies')) {
|
|
51
|
+
return `${value.slice(0, -3)}y`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return value.endsWith('s') ? value.slice(0, -1) : value;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const toPascalCase = (value) => value.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join('');
|