@kollors/deep-json-server 0.5.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kollors/deep-json-server",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "JSON mock server with deep filters and recursive relationship embedding",
5
5
  "type": "module",
6
6
  "types": "./types/index.d.ts",
@@ -52,11 +52,18 @@
52
52
  },
53
53
  "keywords": [
54
54
  "api",
55
+ "crud",
55
56
  "deep-filter",
57
+ "file-upload",
56
58
  "json",
59
+ "json-server",
57
60
  "mock",
61
+ "mock-api",
62
+ "mock-server",
63
+ "openapi",
58
64
  "relations",
59
- "rest"
65
+ "rest",
66
+ "rest-api"
60
67
  ],
61
68
  "author": "kollors",
62
69
  "license": "MIT",
package/src/cli.js CHANGED
@@ -1,28 +1,38 @@
1
1
  import process from 'node:process';
2
2
  import { readServerConfig } from './config.js';
3
3
  import { DEFAULT_HOST, DEFAULT_PORT } from './constants.js';
4
- import { generateOpenApi } from './openapi/index.js';
5
- import { startServer } from './server.js';
4
+ import { createServer } from './server.js';
6
5
 
7
6
  const HELP_TEXT = `Deep JSON Server
8
7
 
9
8
  Использование:
10
- deep-json-server [--openapi] [--files] <server.config.js>
9
+ deep-json-server [--files] [--openapi | --openapi-only] <server.config.js>
11
10
 
12
11
  Параметры:
13
- --openapi Сгенерировать OpenAPI и завершить работу
14
- --files Добавить файловые маршруты в сервер или OpenAPI
15
- --help Показать справку`;
12
+ --files Добавить файловые маршруты в сервер и OpenAPI
13
+ --openapi Сгенерировать OpenAPI и запустить сервер
14
+ --openapi-only Сгенерировать OpenAPI и завершить работу
15
+ --help Показать справку`;
16
16
 
17
17
  const parseArguments = (args) => {
18
- const flags = { files: false, openapi: false };
18
+ const options = { files: false, openapiMode: 'none' };
19
19
  let configPath;
20
20
 
21
21
  args.forEach((argument) => {
22
22
  if (argument === '--files') {
23
- flags.files = true;
23
+ options.files = true;
24
24
  } else if (argument === '--openapi') {
25
- flags.openapi = true;
25
+ if (options.openapiMode !== 'none') {
26
+ throw new Error('Параметры --openapi и --openapi-only нельзя использовать одновременно');
27
+ }
28
+
29
+ options.openapiMode = 'generate';
30
+ } else if (argument === '--openapi-only') {
31
+ if (options.openapiMode !== 'none') {
32
+ throw new Error('Параметры --openapi и --openapi-only нельзя использовать одновременно');
33
+ }
34
+
35
+ options.openapiMode = 'only';
26
36
  } else if (argument.startsWith('-')) {
27
37
  throw new Error(`Неизвестный параметр: ${argument}`);
28
38
  } else if (configPath == null) {
@@ -36,55 +46,46 @@ const parseArguments = (args) => {
36
46
  throw new Error('Укажите путь к файлу конфигурации');
37
47
  }
38
48
 
39
- return { configPath, ...flags };
49
+ return { configPath, ...options };
40
50
  };
41
51
 
42
- const validateModeConfig = (config, { files, openapi }) => {
43
- if (openapi && config.openapiPath == null) {
44
- throw new Error('Для --openapi укажите ключ config.openapi.path');
45
- }
46
-
47
- if (files && config.filesDirectoryPath == null) {
48
- throw new Error('Для --files укажите ключ config.files.directory');
52
+ const validateModeConfig = (config, { files, openapiMode }) => {
53
+ if (openapiMode !== 'none' && config.openapi.path == null) {
54
+ throw new Error(`Для --openapi${openapiMode === 'only' ? '-only' : ''} укажите ключ config.openapi.path`);
49
55
  }
50
56
 
51
- if (files && config.filesMetadataPath == null) {
52
- throw new Error('Для --files укажите ключ config.files.metadata');
57
+ if (files && config.files == null) {
58
+ throw new Error('Для --files укажите секцию config.files');
53
59
  }
54
60
  };
55
61
 
56
- export async function runCli(args = process.argv.slice(2), services = { generateOpenApi, startServer }) {
62
+ export async function runCli(args = process.argv.slice(2), services = { createServer }) {
57
63
  if (args.includes('--help')) {
58
64
  process.stdout.write(`${HELP_TEXT}\n`);
59
65
  return;
60
66
  }
61
67
 
62
- const { configPath, files, openapi } = parseArguments(args);
68
+ const { configPath, files, openapiMode } = parseArguments(args);
63
69
  const config = await readServerConfig(configPath);
64
- const host = config.host ?? process.env.HOST ?? DEFAULT_HOST;
65
- const port = config.port ?? Number(process.env.PORT ?? DEFAULT_PORT);
66
-
67
- validateModeConfig(config, { files, openapi });
68
-
69
- if (openapi) {
70
- await services.generateOpenApi({
71
- databasePath: config.databasePath,
72
- files,
73
- host,
74
- outputPath: config.openapiPath,
75
- port,
76
- schemaPath: config.schemaPath,
77
- });
78
- process.stdout.write(`OpenAPI-схема сохранена в ${config.openapiPath}\n`);
70
+ const host = config.server.host ?? process.env.HOST ?? DEFAULT_HOST;
71
+ const port = config.server.port ?? Number(process.env.PORT ?? DEFAULT_PORT);
72
+
73
+ validateModeConfig(config, { files, openapiMode });
74
+
75
+ const runtimeConfig = { ...config, server: { ...config.server, host, port } };
76
+ const serverFacade = await services.createServer(runtimeConfig, { files });
77
+
78
+ if (openapiMode !== 'none') {
79
+ await serverFacade.openapi();
80
+ process.stdout.write(`OpenAPI-схема сохранена в ${config.openapi.path}\n`);
81
+ }
82
+
83
+ if (openapiMode === 'only') {
79
84
  return;
80
85
  }
81
86
 
82
- await services.startServer({
83
- databasePath: config.databasePath,
84
- filesDirectoryPath: files ? config.filesDirectoryPath : undefined,
85
- filesMetadataPath: files ? config.filesMetadataPath : undefined,
86
- host,
87
- port,
88
- schemaPath: config.schemaPath,
89
- });
87
+ const fastify = serverFacade.fastify();
88
+
89
+ await fastify.listen({ host, port });
90
+ fastify.log.info({ database: 'path' in config.database ? config.database.path : 'memory' }, 'Deep JSON Server запущен');
90
91
  }
package/src/config.js CHANGED
@@ -3,12 +3,28 @@ import { pathToFileURL } from 'node:url';
3
3
  import { isObject } from './utils.js';
4
4
 
5
5
  const CONFIG_KEYS = new Set(['database', 'files', 'openapi', 'server']);
6
- const DATABASE_KEYS = new Set(['path', 'schema']);
7
- const FILES_KEYS = new Set(['directory', 'metadata']);
6
+ const DATABASE_KEYS = new Set(['data', 'path', 'schema']);
7
+ const FILES_KEYS = new Set(['data', 'directory', 'metadata']);
8
8
  const OPENAPI_KEYS = new Set(['path']);
9
- const SERVER_KEYS = new Set(['host', 'port']);
9
+ const SERVER_KEYS = new Set(['host', 'logger', 'maxFileSize', 'maxPageSize', 'port']);
10
10
  let configImportIndex = 0;
11
11
 
12
+ /** @typedef {Record<string, Array<Record<string, unknown>>>} DatabaseData */
13
+ /** @typedef {Record<string, unknown>} DatabaseSchema */
14
+ /** @typedef {{ data: DatabaseData, path?: never, schema?: DatabaseSchema | string } | { data?: never, path: string, schema?: DatabaseSchema | string }} DatabaseConfig */
15
+ /** @typedef {{ content: Uint8Array, directory?: string, mimeType: string, name: string }} MemoryFile */
16
+ /** @typedef {{ data: MemoryFile[], directory?: never, metadata?: never } | { data?: never, directory: string, metadata: string }} FilesConfig */
17
+ /** @typedef {{ path?: string }} OpenapiConfig */
18
+ /** @typedef {{ host?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, port?: number }} ServerConfig */
19
+ /**
20
+ * @typedef {object} DeepJsonServerConfig
21
+ * @property {DatabaseConfig} database Database source and optional schema.
22
+ * @property {FilesConfig} [files] Binary-file storage.
23
+ * @property {OpenapiConfig} [openapi] Generated OpenAPI file.
24
+ * @property {ServerConfig} [server] Runtime settings.
25
+ */
26
+ /** @typedef {{ database: DatabaseConfig, files?: FilesConfig, openapi: OpenapiConfig, server: ServerConfig }} NormalizedServerConfig */
27
+
12
28
  const assertKnownKeys = (value, keys, path) => {
13
29
  const unknownKey = Object.keys(value).find((key) => !keys.has(key));
14
30
 
@@ -17,9 +33,9 @@ const assertKnownKeys = (value, keys, path) => {
17
33
  }
18
34
  };
19
35
 
20
- const getObject = (value, path) => {
21
- if (value == null) {
22
- return {};
36
+ const getObject = (value, path, required = false) => {
37
+ if (value == null && !required) {
38
+ return undefined;
23
39
  }
24
40
 
25
41
  if (!isObject(value)) {
@@ -43,55 +59,145 @@ const getString = (value, path, required = false) => {
43
59
 
44
60
  const resolveConfigPath = (value, directoryPath) => (value == null ? undefined : resolve(directoryPath, value));
45
61
 
46
- export async function readServerConfig(configPath) {
47
- const resolvedConfigPath = resolve(getString(configPath, 'config', true));
48
- let config;
62
+ const normalizeSchema = (schema, directoryPath) => {
63
+ if (schema == null) {
64
+ return undefined;
65
+ }
49
66
 
50
- try {
51
- const configUrl = pathToFileURL(resolvedConfigPath);
67
+ if (typeof schema === 'string') {
68
+ return resolveConfigPath(getString(schema, 'config.database.schema', true), directoryPath);
69
+ }
52
70
 
53
- configUrl.searchParams.set('deep-json-server-import', String(configImportIndex++));
54
- config = (await import(configUrl.href)).default;
55
- } catch (error) {
56
- throw new Error(`Не удалось загрузить конфигурацию ${resolvedConfigPath}: ${error.message}`, { cause: error });
71
+ return getObject(schema, 'config.database.schema', true);
72
+ };
73
+
74
+ const normalizeDatabase = (value, directoryPath) => {
75
+ const database = getObject(value, 'config.database', true);
76
+
77
+ assertKnownKeys(database, DATABASE_KEYS, 'config.database');
78
+
79
+ const hasData = database.data != null;
80
+ const hasPath = database.path != null;
81
+
82
+ if (hasData === hasPath) {
83
+ throw new Error('Укажите ровно один из ключей config.database.path и config.database.data');
84
+ }
85
+
86
+ const schema = normalizeSchema(database.schema, directoryPath);
87
+
88
+ if (hasData) {
89
+ return { data: getObject(database.data, 'config.database.data', true), schema };
90
+ }
91
+
92
+ return { path: resolveConfigPath(getString(database.path, 'config.database.path', true), directoryPath), schema };
93
+ };
94
+
95
+ const normalizeFiles = (value, directoryPath) => {
96
+ const files = getObject(value, 'config.files');
97
+
98
+ if (files == null) {
99
+ return undefined;
100
+ }
101
+
102
+ assertKnownKeys(files, FILES_KEYS, 'config.files');
103
+
104
+ const hasData = files.data != null;
105
+ const hasDiskStorage = files.directory != null || files.metadata != null;
106
+
107
+ if (hasData === hasDiskStorage) {
108
+ throw new Error('Укажите либо config.files.data, либо пару config.files.directory и config.files.metadata');
57
109
  }
58
110
 
111
+ if (hasData) {
112
+ if (!Array.isArray(files.data)) {
113
+ throw new Error('Ключ config.files.data должен содержать массив');
114
+ }
115
+
116
+ return { data: files.data };
117
+ }
118
+
119
+ return {
120
+ directory: resolveConfigPath(getString(files.directory, 'config.files.directory', true), directoryPath),
121
+ metadata: resolveConfigPath(getString(files.metadata, 'config.files.metadata', true), directoryPath),
122
+ };
123
+ };
124
+
125
+ /**
126
+ * Validates configuration and resolves relative paths.
127
+ * @param {DeepJsonServerConfig} config Server configuration.
128
+ * @param {string} [directoryPath] Base directory for relative paths.
129
+ * @returns {NormalizedServerConfig} Normalized configuration.
130
+ */
131
+ export const normalizeServerConfig = (config, directoryPath = '.') => {
59
132
  if (!isObject(config)) {
60
- throw new Error('Конфигурация сервера должна экспортировать JSON-объект через export default');
133
+ throw new Error('Конфигурация сервера должна содержать JSON-объект');
61
134
  }
62
135
 
63
136
  assertKnownKeys(config, CONFIG_KEYS, 'config');
64
137
 
65
- const database = getObject(config.database, 'config.database');
66
- const files = getObject(config.files, 'config.files');
67
- const openapi = getObject(config.openapi, 'config.openapi');
68
- const server = getObject(config.server, 'config.server');
138
+ const database = normalizeDatabase(config.database, directoryPath);
139
+ const files = normalizeFiles(config.files, directoryPath);
140
+ const openapi = getObject(config.openapi, 'config.openapi') ?? {};
141
+ const server = getObject(config.server, 'config.server') ?? {};
69
142
 
70
- assertKnownKeys(database, DATABASE_KEYS, 'config.database');
71
- assertKnownKeys(files, FILES_KEYS, 'config.files');
72
143
  assertKnownKeys(openapi, OPENAPI_KEYS, 'config.openapi');
73
144
  assertKnownKeys(server, SERVER_KEYS, 'config.server');
74
145
 
75
- const directoryPath = dirname(resolvedConfigPath);
76
- const databasePath = getString(database.path, 'config.database.path', true);
77
- const schemaPath = getString(database.schema, 'config.database.schema');
78
146
  const openapiPath = getString(openapi.path, 'config.openapi.path');
79
- const filesDirectory = getString(files.directory, 'config.files.directory');
80
- const filesMetadata = getString(files.metadata, 'config.files.metadata');
81
147
  const host = getString(server.host, 'config.server.host');
82
148
 
83
149
  if (server.port != null && (!Number.isInteger(server.port) || server.port < 0 || server.port > 65_535)) {
84
150
  throw new Error('Ключ config.server.port должен быть целым числом от 0 до 65535');
85
151
  }
86
152
 
153
+ if (server.maxPageSize != null && (!Number.isInteger(server.maxPageSize) || server.maxPageSize < 1)) {
154
+ throw new Error('Ключ config.server.maxPageSize должен быть положительным целым числом');
155
+ }
156
+
157
+ if (server.maxFileSize != null && (!Number.isInteger(server.maxFileSize) || server.maxFileSize < 1)) {
158
+ throw new Error('Ключ config.server.maxFileSize должен быть положительным целым числом');
159
+ }
160
+
161
+ if (server.logger != null && typeof server.logger !== 'boolean' && !isObject(server.logger)) {
162
+ throw new Error('Ключ config.server.logger должен содержать boolean или JSON-объект');
163
+ }
164
+
87
165
  return {
88
- configPath: resolvedConfigPath,
89
- databasePath: resolveConfigPath(databasePath, directoryPath),
90
- filesDirectoryPath: resolveConfigPath(filesDirectory, directoryPath),
91
- filesMetadataPath: resolveConfigPath(filesMetadata, directoryPath),
92
- host,
93
- openapiPath: resolveConfigPath(openapiPath, directoryPath),
94
- port: server.port,
95
- schemaPath: resolveConfigPath(schemaPath, directoryPath),
166
+ database,
167
+ files,
168
+ openapi: { path: resolveConfigPath(openapiPath, directoryPath) },
169
+ server: {
170
+ host,
171
+ logger: server.logger,
172
+ maxFileSize: server.maxFileSize,
173
+ maxPageSize: server.maxPageSize,
174
+ port: server.port,
175
+ },
96
176
  };
177
+ };
178
+
179
+ /**
180
+ * Loads an ES module config and resolves paths from its directory.
181
+ * @param {string} configPath Configuration module path.
182
+ * @returns {Promise<NormalizedServerConfig>} Normalized configuration.
183
+ */
184
+ export async function readServerConfig(configPath) {
185
+ const resolvedConfigPath = resolve(getString(configPath, 'config', true));
186
+ let config;
187
+
188
+ try {
189
+ const configUrl = pathToFileURL(resolvedConfigPath);
190
+
191
+ // Bypass the module cache so repeated reads use the latest config.
192
+ configUrl.searchParams.set('deep-json-server-import', String(configImportIndex++));
193
+ config = (await import(configUrl.href)).default;
194
+ } catch (error) {
195
+ throw new Error(`Не удалось загрузить конфигурацию ${resolvedConfigPath}: ${error.message}`, { cause: error });
196
+ }
197
+
198
+ if (!isObject(config)) {
199
+ throw new Error('Конфигурация сервера должна экспортировать JSON-объект через export default');
200
+ }
201
+
202
+ return normalizeServerConfig(config, dirname(resolvedConfigPath));
97
203
  }
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_HOST = '127.0.0.1';
2
2
  export const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024;
3
+ export const DEFAULT_MAX_PAGE_SIZE = 1000;
3
4
  export const DEFAULT_PAGE_SIZE = 10;
4
5
  export const DEFAULT_PORT = 4001;
5
- export const MAX_PAGE_SIZE = 1000;
package/src/database.js CHANGED
@@ -1,8 +1,16 @@
1
- import { randomBytes } from 'node:crypto';
2
1
  import { readFile } from 'node:fs/promises';
3
2
  import { resolve } from 'node:path';
4
3
  import { JSONFilePreset } from 'lowdb/node';
5
- import { createHttpError, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
4
+ import { createHttpError, createSerialQueue, createUniqueId, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
5
+
6
+ /** @typedef {{ data: import('./config.js').DatabaseData }} DatabaseContainer */
7
+ /**
8
+ * @typedef {object} DatabaseStore
9
+ * @property {DatabaseContainer} database Current database container.
10
+ * @property {string} [path] Resolved database file path.
11
+ * @property {() => Promise<import('./config.js').DatabaseData>} read Returns current data and reloads disk-backed sources.
12
+ * @property {<T>(operation: (database: DatabaseContainer) => T) => Promise<T>} update Runs a serialized update.
13
+ */
6
14
 
7
15
  const RESOURCE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
8
16
 
@@ -72,12 +80,11 @@ export const readJsonObject = async (path, label) => {
72
80
 
73
81
  export const readDatabaseFile = async (databasePath) => validateDatabase(await readJsonObject(databasePath, 'Файл базы данных'));
74
82
 
75
- /** @returns {Promise<any>} Internal LowDB-backed store. */
76
- export const createDatabaseStore = async (databasePath) => {
83
+ const createDiskDatabaseStore = async (databasePath) => {
77
84
  const resolvedDatabasePath = resolveDatabasePath(databasePath);
78
85
  const initialData = await readDatabaseFile(resolvedDatabasePath);
79
86
  const database = await JSONFilePreset(resolvedDatabasePath, initialData);
80
- let writeQueue = Promise.resolve();
87
+ const schedule = createSerialQueue();
81
88
 
82
89
  const read = async () => {
83
90
  database.data = await readDatabaseFile(resolvedDatabasePath);
@@ -85,8 +92,8 @@ export const createDatabaseStore = async (databasePath) => {
85
92
  return database.data;
86
93
  };
87
94
 
88
- const update = (operation) => {
89
- const pendingOperation = writeQueue.then(async () => {
95
+ const update = (operation) =>
96
+ schedule(async () => {
90
97
  await read();
91
98
 
92
99
  const result = operation(database);
@@ -97,14 +104,35 @@ export const createDatabaseStore = async (databasePath) => {
97
104
  return result;
98
105
  });
99
106
 
100
- writeQueue = pendingOperation.catch(() => undefined);
107
+ return { database, path: resolvedDatabasePath, read, update };
108
+ };
101
109
 
102
- return pendingOperation;
103
- };
110
+ const createMemoryDatabaseStore = (sourceData) => {
111
+ const database = { data: validateDatabase(structuredClone(sourceData)) };
112
+ const schedule = createSerialQueue();
104
113
 
105
- return { database, path: resolvedDatabasePath, read, update };
114
+ const read = async () => database.data;
115
+ const update = (operation) =>
116
+ schedule(() => {
117
+ const draft = { data: structuredClone(database.data) };
118
+ const result = operation(draft);
119
+
120
+ validateDatabase(draft.data);
121
+ database.data = draft.data;
122
+
123
+ return result;
124
+ });
125
+
126
+ return { database, read, update };
106
127
  };
107
128
 
129
+ /**
130
+ * Creates a disk- or memory-backed database with serialized updates.
131
+ * @param {import('./config.js').DatabaseConfig} config Database source.
132
+ * @returns {Promise<DatabaseStore>} Database store.
133
+ */
134
+ export const createDatabaseStore = async (config) => ('data' in config ? createMemoryDatabaseStore(config.data) : createDiskDatabaseStore(config.path));
135
+
108
136
  export const getCollection = (database, resource) => {
109
137
  const collection = isSafeKey(resource) ? database.data[resource] : undefined;
110
138
 
@@ -117,12 +145,4 @@ export const getCollection = (database, resource) => {
117
145
 
118
146
  export const findItem = (collection, id) => collection.find((item) => String(item.id) === String(id));
119
147
 
120
- export const createId = (collection) => {
121
- let id;
122
-
123
- do {
124
- id = randomBytes(8).toString('base64url');
125
- } while (findItem(collection, id) != null);
126
-
127
- return id;
128
- };
148
+ export const createId = (collection) => createUniqueId((id) => findItem(collection, id) != null);