@kollors/deep-json-server 0.6.0 → 0.8.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.
Files changed (107) hide show
  1. package/README.md +52 -20
  2. package/README.ru.md +52 -20
  3. package/dist/bin/deep-json-server.d.ts +2 -0
  4. package/dist/bin/deep-json-server.js +11 -0
  5. package/dist/bin/deep-json-server.js.map +1 -0
  6. package/dist/index.d.ts +5 -0
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/src/cli.d.ts +4 -0
  10. package/dist/src/cli.js +80 -0
  11. package/dist/src/cli.js.map +1 -0
  12. package/dist/src/config.d.ts +54 -0
  13. package/dist/src/config.js +145 -0
  14. package/dist/src/config.js.map +1 -0
  15. package/{types → dist}/src/constants.d.ts +1 -1
  16. package/{src → dist/src}/constants.js +2 -1
  17. package/dist/src/constants.js.map +1 -0
  18. package/dist/src/database.d.ts +20 -0
  19. package/dist/src/database.js +136 -0
  20. package/dist/src/database.js.map +1 -0
  21. package/dist/src/files/contract.d.ts +67 -0
  22. package/dist/src/files/contract.js +106 -0
  23. package/dist/src/files/contract.js.map +1 -0
  24. package/dist/src/files/disk-store.d.ts +5 -0
  25. package/dist/src/files/disk-store.js +313 -0
  26. package/dist/src/files/disk-store.js.map +1 -0
  27. package/dist/src/files/index.d.ts +5 -0
  28. package/dist/src/files/index.js +6 -0
  29. package/dist/src/files/index.js.map +1 -0
  30. package/dist/src/files/memory-store.d.ts +3 -0
  31. package/dist/src/files/memory-store.js +80 -0
  32. package/dist/src/files/memory-store.js.map +1 -0
  33. package/dist/src/files/routes.d.ts +6 -0
  34. package/dist/src/files/routes.js +77 -0
  35. package/dist/src/files/routes.js.map +1 -0
  36. package/dist/src/openapi/config.d.ts +11 -0
  37. package/dist/src/openapi/config.js +204 -0
  38. package/dist/src/openapi/config.js.map +1 -0
  39. package/dist/src/openapi/document.d.ts +9 -0
  40. package/dist/src/openapi/document.js +338 -0
  41. package/dist/src/openapi/document.js.map +1 -0
  42. package/dist/src/openapi/index.d.ts +10 -0
  43. package/dist/src/openapi/index.js +27 -0
  44. package/dist/src/openapi/index.js.map +1 -0
  45. package/dist/src/openapi/inference.d.ts +14 -0
  46. package/dist/src/openapi/inference.js +145 -0
  47. package/dist/src/openapi/inference.js.map +1 -0
  48. package/dist/src/query/filter.d.ts +6 -0
  49. package/dist/src/query/filter.js +248 -0
  50. package/dist/src/query/filter.js.map +1 -0
  51. package/{src → dist/src}/query/index.js +1 -0
  52. package/dist/src/query/index.js.map +1 -0
  53. package/dist/src/query/pagination.d.ts +11 -0
  54. package/dist/src/query/pagination.js +28 -0
  55. package/dist/src/query/pagination.js.map +1 -0
  56. package/dist/src/query/sort.d.ts +1 -0
  57. package/dist/src/query/sort.js +54 -0
  58. package/dist/src/query/sort.js.map +1 -0
  59. package/dist/src/relation-metadata.d.ts +10 -0
  60. package/dist/src/relation-metadata.js +32 -0
  61. package/dist/src/relation-metadata.js.map +1 -0
  62. package/dist/src/relations.d.ts +12 -0
  63. package/dist/src/relations.js +155 -0
  64. package/dist/src/relations.js.map +1 -0
  65. package/dist/src/server.d.ts +13 -0
  66. package/dist/src/server.js +188 -0
  67. package/dist/src/server.js.map +1 -0
  68. package/dist/src/types.d.ts +52 -0
  69. package/dist/src/types.js +2 -0
  70. package/dist/src/types.js.map +1 -0
  71. package/dist/src/utils.d.ts +20 -0
  72. package/dist/src/utils.js +63 -0
  73. package/dist/src/utils.js.map +1 -0
  74. package/package.json +24 -16
  75. package/bin/deep-json-server.js +0 -11
  76. package/index.js +0 -7
  77. package/src/cli.js +0 -91
  78. package/src/config.js +0 -194
  79. package/src/database.js +0 -157
  80. package/src/files.js +0 -368
  81. package/src/openapi/config.js +0 -124
  82. package/src/openapi/document.js +0 -317
  83. package/src/openapi/index.js +0 -36
  84. package/src/openapi/inference.js +0 -187
  85. package/src/query/filter.js +0 -282
  86. package/src/query/pagination.js +0 -44
  87. package/src/query/sort.js +0 -63
  88. package/src/relation-metadata.js +0 -40
  89. package/src/relations.js +0 -173
  90. package/src/server.js +0 -248
  91. package/src/utils.js +0 -54
  92. package/types/index.d.ts +0 -11
  93. package/types/src/config.d.ts +0 -86
  94. package/types/src/database.d.ts +0 -11
  95. package/types/src/files.d.ts +0 -24
  96. package/types/src/openapi/config.d.ts +0 -3
  97. package/types/src/openapi/document.d.ts +0 -11
  98. package/types/src/openapi/index.d.ts +0 -9
  99. package/types/src/openapi/inference.d.ts +0 -9
  100. package/types/src/query/filter.d.ts +0 -3
  101. package/types/src/query/pagination.d.ts +0 -8
  102. package/types/src/query/sort.d.ts +0 -1
  103. package/types/src/relation-metadata.d.ts +0 -9
  104. package/types/src/relations.d.ts +0 -3
  105. package/types/src/server.d.ts +0 -13
  106. package/types/src/utils.d.ts +0 -10
  107. /package/{types → dist}/src/query/index.d.ts +0 -0
package/src/files.js DELETED
@@ -1,368 +0,0 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { createReadStream, createWriteStream } from 'node:fs';
3
- import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
- import { basename, dirname, join, resolve } from 'node:path';
5
- import { Readable, Transform } from 'node:stream';
6
- import { pipeline } from 'node:stream/promises';
7
- import { createHttpError, isObject } from './utils.js';
8
-
9
- const createFileId = (files) => {
10
- let id;
11
-
12
- do {
13
- id = randomBytes(8).toString('base64url');
14
- } while (files.some((file) => file.id === id));
15
-
16
- return id;
17
- };
18
-
19
- const validateFileMetadata = (files, metadataPath) => {
20
- if (!Array.isArray(files)) {
21
- throw new Error(`Файл метаданных ${metadataPath} должен содержать JSON-массив`);
22
- }
23
-
24
- const ids = new Set();
25
-
26
- files.forEach((file, index) => {
27
- if (
28
- !isObject(file) ||
29
- typeof file.id !== 'string' ||
30
- file.id === '' ||
31
- typeof file.mimeType !== 'string' ||
32
- file.mimeType === '' ||
33
- typeof file.name !== 'string' ||
34
- file.name === '' ||
35
- !Number.isInteger(file.size) ||
36
- file.size < 0 ||
37
- file.url !== `/_files/${file.id}`
38
- ) {
39
- throw new Error(`Некорректная запись ${index} в файле метаданных ${metadataPath}`);
40
- }
41
-
42
- if (ids.has(file.id)) {
43
- throw new Error(`Файл метаданных ${metadataPath} содержит повторяющийся id «${file.id}»`);
44
- }
45
-
46
- ids.add(file.id);
47
- });
48
-
49
- return files;
50
- };
51
-
52
- const readMetadata = async (metadataPath) => {
53
- try {
54
- return validateFileMetadata(JSON.parse(await readFile(metadataPath, 'utf8')), metadataPath);
55
- } catch (error) {
56
- if (error?.code === 'ENOENT') {
57
- return [];
58
- }
59
-
60
- throw error;
61
- }
62
- };
63
-
64
- const writeMetadata = async (metadataPath, files) => {
65
- const temporaryPath = `${metadataPath}.${randomBytes(6).toString('hex')}.tmp`;
66
-
67
- try {
68
- await writeFile(temporaryPath, JSON.stringify(files, null, 2), { encoding: 'utf8', flag: 'wx' });
69
- await rename(temporaryPath, metadataPath);
70
- } catch (error) {
71
- await rm(temporaryPath, { force: true });
72
- throw error;
73
- }
74
- };
75
-
76
- const getContentName = (value) => {
77
- if (typeof value !== 'string' || value === '') {
78
- throw createHttpError(400, 'Заголовок Content-Name обязателен');
79
- }
80
-
81
- let name;
82
-
83
- try {
84
- name = decodeURIComponent(value).replaceAll('\\', '/');
85
- } catch {
86
- throw createHttpError(400, 'Заголовок Content-Name содержит некорректное значение');
87
- }
88
-
89
- const parts = name.split('/');
90
-
91
- if (name.startsWith('/') || parts.some((part) => part === '' || part === '.' || part === '..' || part.includes('\0'))) {
92
- throw createHttpError(400, 'Заголовок Content-Name должен содержать безопасный относительный путь');
93
- }
94
-
95
- return name;
96
- };
97
-
98
- const getMimeType = (value) => {
99
- const mimeType = typeof value === 'string' ? value.split(';', 1)[0].trim().toLowerCase() : '';
100
-
101
- if (mimeType === '' || !/^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/.test(mimeType)) {
102
- throw createHttpError(400, 'Заголовок Content-Type должен содержать корректный MIME-тип');
103
- }
104
-
105
- return mimeType;
106
- };
107
-
108
- const createSizeLimiter = (maxFileSize, onSize) => {
109
- let size = 0;
110
-
111
- return new Transform({
112
- transform(chunk, _encoding, callback) {
113
- size += chunk.length;
114
-
115
- if (size > maxFileSize) {
116
- callback(createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`));
117
- return;
118
- }
119
-
120
- onSize(size);
121
- callback(null, chunk);
122
- },
123
- });
124
- };
125
-
126
- const createDiskFileStore = async ({ directory: sourceDirectoryPath, metadata: sourceMetadataPath }) => {
127
- if (typeof sourceDirectoryPath !== 'string' || sourceDirectoryPath.trim() === '') {
128
- throw new Error('Путь к директории файлов не должен быть пустым');
129
- }
130
-
131
- if (typeof sourceMetadataPath !== 'string' || sourceMetadataPath.trim() === '') {
132
- throw new Error('Путь к файлу метаданных не должен быть пустым');
133
- }
134
-
135
- const directoryPath = resolve(sourceDirectoryPath);
136
- const metadataPath = resolve(sourceMetadataPath);
137
- let operationQueue = Promise.resolve();
138
-
139
- await Promise.all([mkdir(directoryPath, { recursive: true }), mkdir(dirname(metadataPath), { recursive: true })]);
140
- await readMetadata(metadataPath);
141
-
142
- const schedule = (operation) => {
143
- const pendingOperation = operationQueue.then(operation);
144
-
145
- operationQueue = pendingOperation.catch(() => undefined);
146
-
147
- return pendingOperation;
148
- };
149
-
150
- const get = (id) =>
151
- schedule(async () => {
152
- const files = await readMetadata(metadataPath);
153
- const file = files.find((item) => item.id === id);
154
-
155
- if (file == null) {
156
- throw createHttpError(404, 'Файл не найден');
157
- }
158
-
159
- const path = join(directoryPath, file.id);
160
-
161
- try {
162
- await access(path);
163
- } catch (error) {
164
- if (error?.code === 'ENOENT') {
165
- throw createHttpError(404, 'Файл не найден');
166
- }
167
-
168
- throw error;
169
- }
170
-
171
- return { file, stream: createReadStream(path) };
172
- });
173
-
174
- const upload = ({ maxFileSize, mimeType, name, stream }) =>
175
- schedule(async () => {
176
- const files = await readMetadata(metadataPath);
177
- const id = createFileId(files);
178
- const path = join(directoryPath, id);
179
- const temporaryPath = `${path}.upload`;
180
- let size = 0;
181
-
182
- try {
183
- await pipeline(
184
- stream,
185
- createSizeLimiter(maxFileSize, (value) => (size = value)),
186
- createWriteStream(temporaryPath, { flags: 'wx' }),
187
- );
188
- await rename(temporaryPath, path);
189
-
190
- const file = { id, mimeType, name, size, url: `/_files/${id}` };
191
-
192
- files.push(file);
193
- await writeMetadata(metadataPath, files);
194
-
195
- return file;
196
- } catch (error) {
197
- await Promise.all([rm(temporaryPath, { force: true }), rm(path, { force: true })]);
198
- throw error;
199
- }
200
- });
201
-
202
- const remove = (id) =>
203
- schedule(async () => {
204
- const files = await readMetadata(metadataPath);
205
- const index = files.findIndex((file) => file.id === id);
206
-
207
- if (index === -1) {
208
- throw createHttpError(404, 'Файл не найден');
209
- }
210
-
211
- const [file] = files.splice(index, 1);
212
- const path = join(directoryPath, file.id);
213
- const temporaryPath = `${path}.${randomBytes(6).toString('hex')}.delete`;
214
-
215
- try {
216
- await rename(path, temporaryPath);
217
- } catch (error) {
218
- if (error?.code === 'ENOENT') {
219
- throw createHttpError(404, 'Файл не найден');
220
- }
221
-
222
- throw error;
223
- }
224
-
225
- try {
226
- await writeMetadata(metadataPath, files);
227
- } catch (error) {
228
- await rename(temporaryPath, path);
229
- throw error;
230
- }
231
-
232
- await rm(temporaryPath, { force: true }).catch(() => undefined);
233
-
234
- return file;
235
- });
236
-
237
- return { get, remove, upload };
238
- };
239
-
240
- const createMemoryFileStore = (sourceFiles) => {
241
- const ids = new Set();
242
- const storedFiles = sourceFiles.map((sourceFile, index) => {
243
- if (
244
- !isObject(sourceFile) ||
245
- typeof sourceFile.id !== 'string' ||
246
- sourceFile.id === '' ||
247
- typeof sourceFile.mimeType !== 'string' ||
248
- sourceFile.mimeType === '' ||
249
- typeof sourceFile.name !== 'string' ||
250
- sourceFile.name === '' ||
251
- !(sourceFile.content instanceof Uint8Array)
252
- ) {
253
- throw new Error(`Некорректная запись ${index} в config.files.data`);
254
- }
255
-
256
- if (ids.has(sourceFile.id)) {
257
- throw new Error(`config.files.data содержит повторяющийся id «${sourceFile.id}»`);
258
- }
259
-
260
- ids.add(sourceFile.id);
261
-
262
- const content = Buffer.from(sourceFile.content);
263
-
264
- return {
265
- content,
266
- file: { id: sourceFile.id, mimeType: sourceFile.mimeType, name: sourceFile.name, size: content.length, url: `/_files/${sourceFile.id}` },
267
- };
268
- });
269
- let operationQueue = Promise.resolve();
270
-
271
- const schedule = (operation) => {
272
- const pendingOperation = operationQueue.then(operation);
273
-
274
- operationQueue = pendingOperation.catch(() => undefined);
275
-
276
- return pendingOperation;
277
- };
278
-
279
- const get = (id) =>
280
- schedule(async () => {
281
- const storedFile = storedFiles.find(({ file }) => file.id === id);
282
-
283
- if (storedFile == null) {
284
- throw createHttpError(404, 'Файл не найден');
285
- }
286
-
287
- return { file: storedFile.file, stream: Readable.from([storedFile.content]) };
288
- });
289
-
290
- const upload = ({ maxFileSize, mimeType, name, stream }) =>
291
- schedule(async () => {
292
- const chunks = [];
293
- let size = 0;
294
-
295
- for await (const chunk of stream) {
296
- const buffer = Buffer.from(chunk);
297
-
298
- size += buffer.length;
299
-
300
- if (size > maxFileSize) {
301
- throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
302
- }
303
-
304
- chunks.push(buffer);
305
- }
306
-
307
- const id = createFileId(storedFiles.map(({ file }) => file));
308
- const file = { id, mimeType, name, size, url: `/_files/${id}` };
309
-
310
- storedFiles.push({ content: Buffer.concat(chunks), file });
311
-
312
- return file;
313
- });
314
-
315
- const remove = (id) =>
316
- schedule(async () => {
317
- const index = storedFiles.findIndex(({ file }) => file.id === id);
318
-
319
- if (index === -1) {
320
- throw createHttpError(404, 'Файл не найден');
321
- }
322
-
323
- return storedFiles.splice(index, 1)[0].file;
324
- });
325
-
326
- return { get, remove, upload };
327
- };
328
-
329
- /** @param {import('./config.js').FilesConfig} config File storage configuration. */
330
- export const createFileStore = async (config) => ('data' in config ? createMemoryFileStore(config.data) : createDiskFileStore(config));
331
-
332
- const getDownloadName = (name) => encodeURIComponent(basename(name)).replaceAll("'", '%27');
333
-
334
- export const registerFileRoutes = (server, { maxFileSize, store }) => {
335
- server.register((fileServer, _options, done) => {
336
- fileServer.removeAllContentTypeParsers();
337
- fileServer.addContentTypeParser('*', (_request, payload, parserDone) => parserDone(null, payload));
338
-
339
- fileServer.post('/_files', async (request, reply) => {
340
- const contentLength = Number(request.headers['content-length']);
341
-
342
- if (Number.isFinite(contentLength) && contentLength > maxFileSize) {
343
- throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
344
- }
345
-
346
- const file = await store.upload({
347
- maxFileSize,
348
- mimeType: getMimeType(request.headers['content-type']),
349
- name: getContentName(request.headers['content-name']),
350
- stream: request.body,
351
- });
352
-
353
- return reply.code(201).send(file);
354
- });
355
-
356
- fileServer.get('/_files/:id', async (request, reply) => {
357
- const { file, stream } = await store.get(request.params.id);
358
-
359
- reply.header('Content-Disposition', `inline; filename*=UTF-8''${getDownloadName(file.name)}`);
360
- reply.type(file.mimeType);
361
-
362
- return reply.send(stream);
363
- });
364
-
365
- fileServer.delete('/_files/:id', async (request) => store.remove(request.params.id));
366
- done();
367
- });
368
- };
@@ -1,124 +0,0 @@
1
- import { readJsonObject } from '../database.js';
2
- import { isObject } from '../utils.js';
3
- import { applyRequiredFields, getSchemasAtPath, updateSchemasAtPath } from './inference.js';
4
-
5
- const validateSchemaOverride = (schema, path) => {
6
- if (!isObject(schema)) {
7
- throw new Error(`OpenAPI-схема свойства «${path}» должна содержать JSON-объект`);
8
- }
9
-
10
- if (schema.properties != null) {
11
- if (!isObject(schema.properties)) {
12
- throw new Error(`properties свойства «${path}» должен содержать JSON-объект`);
13
- }
14
-
15
- Object.entries(schema.properties).forEach(([key, value]) => {
16
- validateSchemaOverride(value, `${path}.${key}`);
17
- });
18
- }
19
-
20
- if (schema.items != null) {
21
- validateSchemaOverride(schema.items, `${path}[]`);
22
- }
23
-
24
- if (schema.oneOf != null) {
25
- if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) {
26
- throw new Error(`oneOf свойства «${path}» должен содержать непустой массив`);
27
- }
28
-
29
- schema.oneOf.forEach((value, index) => {
30
- validateSchemaOverride(value, `${path}.oneOf[${index}]`);
31
- });
32
- }
33
- };
34
-
35
- export const validateSchemaConfig = (schemaConfig, resources) => {
36
- if (
37
- Object.hasOwn(schemaConfig, '$info') &&
38
- (!isObject(schemaConfig.$info) || !['title', 'version'].every((key) => typeof schemaConfig.$info[key] === 'string' && schemaConfig.$info[key].trim() !== ''))
39
- ) {
40
- throw new Error('$info должен содержать непустые строковые поля title и version');
41
- }
42
-
43
- if (Object.hasOwn(schemaConfig, '$schema') && !isObject(schemaConfig.$schema)) {
44
- throw new Error('$schema должен содержать JSON-объект');
45
- }
46
-
47
- const resourceConfigs = schemaConfig.$schema ?? {};
48
-
49
- Object.entries(resourceConfigs).forEach(([resource, resourceConfig]) => {
50
- if (!resources.includes(resource)) {
51
- throw new Error(`В $schema указан неизвестный ресурс «${resource}»`);
52
- }
53
-
54
- if (!isObject(resourceConfig)) {
55
- throw new Error(`Настройки ресурса «${resource}» должны содержать JSON-объект`);
56
- }
57
-
58
- if (resourceConfig.name != null && (typeof resourceConfig.name !== 'string' || resourceConfig.name.trim() === '')) {
59
- throw new Error(`$schema.${resource}.name должен содержать непустую строку`);
60
- }
61
-
62
- if (resourceConfig.required != null && (!Array.isArray(resourceConfig.required) || resourceConfig.required.some((path) => typeof path !== 'string' || path === ''))) {
63
- throw new Error(`$schema.${resource}.required должен содержать массив непустых строк`);
64
- }
65
-
66
- if (
67
- resourceConfig.formats != null &&
68
- (!isObject(resourceConfig.formats) || Object.entries(resourceConfig.formats).some(([path, format]) => path === '' || typeof format !== 'string' || format === ''))
69
- ) {
70
- throw new Error(`$schema.${resource}.formats должен содержать JSON-объект с непустыми строковыми путями и форматами`);
71
- }
72
-
73
- if (resourceConfig.properties != null) {
74
- if (!isObject(resourceConfig.properties)) {
75
- throw new Error(`$schema.${resource}.properties должен содержать JSON-объект`);
76
- }
77
-
78
- Object.entries(resourceConfig.properties).forEach(([key, value]) => {
79
- validateSchemaOverride(value, `${resource}.${key}`);
80
- });
81
- }
82
- });
83
-
84
- return resourceConfigs;
85
- };
86
-
87
- export const applyConfiguredFields = (schema, resource, resourceConfig) => {
88
- const requiredFields = Array.isArray(resourceConfig.required) ? resourceConfig.required : [];
89
- const formats = isObject(resourceConfig.formats) ? resourceConfig.formats : {};
90
-
91
- [...requiredFields, ...Object.keys(formats)].forEach((path) => {
92
- if (getSchemasAtPath(schema, path.split('.')).length === 0) {
93
- throw new Error(`Путь «${path}» из настроек ресурса «${resource}» отсутствует в итоговой схеме`);
94
- }
95
- });
96
-
97
- const schemaWithFormats = Object.entries(formats).reduce((result, [path, format]) => {
98
- const schemas = getSchemasAtPath(result, path.split('.'));
99
-
100
- if (!schemas.some((nestedSchema) => nestedSchema.type === 'string')) {
101
- throw new Error(`Формат «${format}» для пути «${resource}.${path}» можно применить только к строковому полю`);
102
- }
103
-
104
- return updateSchemasAtPath(result, path.split('.'), (nestedSchema) => (nestedSchema.type === 'string' ? { ...nestedSchema, format } : nestedSchema));
105
- }, schema);
106
-
107
- return applyRequiredFields(schemaWithFormats, '', new Set(requiredFields));
108
- };
109
-
110
- export const resolveSchemaConfig = async (schema) => {
111
- if (schema == null) {
112
- return {};
113
- }
114
-
115
- if (typeof schema === 'string') {
116
- return readJsonObject(schema, 'Файл схемы базы данных');
117
- }
118
-
119
- if (!isObject(schema)) {
120
- throw new Error('Схема базы данных должна содержать JSON-объект');
121
- }
122
-
123
- return structuredClone(schema);
124
- };