@kollors/deep-json-server 0.4.0 → 0.6.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/src/cli.js CHANGED
@@ -1,78 +1,91 @@
1
1
  import process from 'node:process';
2
+ import { readServerConfig } from './config.js';
2
3
  import { DEFAULT_HOST, DEFAULT_PORT } from './constants.js';
3
- import { generateOpenApi } from './openapi/index.js';
4
- import { startServer } from './server.js';
4
+ import { createServer } from './server.js';
5
5
 
6
6
  const HELP_TEXT = `Deep JSON Server
7
7
 
8
8
  Использование:
9
- deep-json-server <database.json> [--schema <database-schema.json>] [--host <host>] [--port <port>]
10
- deep-json-server <database.json> --generate <database-schema.json> <openapi-schema.yaml> [--host <host>] [--port <port>]
9
+ deep-json-server [--files] [--openapi | --openapi-only] <server.config.js>
11
10
 
12
11
  Параметры:
13
- --generate Сгенерировать OpenAPI и завершить работу
14
- --host, -h Адрес сервера (по умолчанию 127.0.0.1)
15
- --port, -p Порт сервера (по умолчанию 4001)
16
- --schema Проверять запросы записи по указанной схеме
17
- --help Показать справку`;
18
-
19
- const parseOptions = (args, allowedOptions) => {
20
- const options = {};
21
-
22
- for (let index = 0; index < args.length; index += 2) {
23
- const option = args[index];
24
- const value = args[index + 1];
25
-
26
- if (!allowedOptions.includes(option)) {
27
- throw new Error(`Неизвестный параметр: ${option}`);
12
+ --files Добавить файловые маршруты в сервер и OpenAPI
13
+ --openapi Сгенерировать OpenAPI и запустить сервер
14
+ --openapi-only Сгенерировать OpenAPI и завершить работу
15
+ --help Показать справку`;
16
+
17
+ const parseArguments = (args) => {
18
+ const options = { files: false, openapiMode: 'none' };
19
+ let configPath;
20
+
21
+ args.forEach((argument) => {
22
+ if (argument === '--files') {
23
+ options.files = true;
24
+ } else if (argument === '--openapi') {
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';
36
+ } else if (argument.startsWith('-')) {
37
+ throw new Error(`Неизвестный параметр: ${argument}`);
38
+ } else if (configPath == null) {
39
+ configPath = argument;
40
+ } else {
41
+ throw new Error('Можно указать только один файл конфигурации');
28
42
  }
43
+ });
29
44
 
30
- if (value == null || value.startsWith('-')) {
31
- throw new Error(`Не указано значение параметра ${option}`);
32
- }
45
+ if (configPath == null) {
46
+ throw new Error('Укажите путь к файлу конфигурации');
47
+ }
33
48
 
34
- const optionName = ['--host', '-h'].includes(option) ? 'host' : ['--port', '-p'].includes(option) ? 'port' : 'schemaPath';
49
+ return { configPath, ...options };
50
+ };
35
51
 
36
- options[optionName] = value;
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`);
37
55
  }
38
56
 
39
- return options;
57
+ if (files && config.files == null) {
58
+ throw new Error('Для --files укажите секцию config.files');
59
+ }
40
60
  };
41
61
 
42
- export async function runCli(args = process.argv.slice(2)) {
62
+ export async function runCli(args = process.argv.slice(2), services = { createServer }) {
43
63
  if (args.includes('--help')) {
44
64
  process.stdout.write(`${HELP_TEXT}\n`);
45
65
  return;
46
66
  }
47
67
 
48
- const [databasePath] = args;
49
-
50
- if (databasePath == null || databasePath.startsWith('-')) {
51
- throw new Error('Укажите путь к JSON-базе данных');
52
- }
53
-
54
- const generateIndex = args.indexOf('--generate');
68
+ const { configPath, files, openapiMode } = parseArguments(args);
69
+ const config = await readServerConfig(configPath);
70
+ const host = config.server.host ?? process.env.HOST ?? DEFAULT_HOST;
71
+ const port = config.server.port ?? Number(process.env.PORT ?? DEFAULT_PORT);
55
72
 
56
- if (generateIndex !== -1) {
57
- const schemaPath = args[generateIndex + 1];
58
- const outputPath = args[generateIndex + 2];
73
+ validateModeConfig(config, { files, openapiMode });
59
74
 
60
- if (generateIndex !== 1 || schemaPath == null || outputPath == null) {
61
- throw new Error('Используйте: deep-json-server <database.json> --generate <database-schema.json> <openapi-schema.yaml> [--host <host>] [--port <port>]');
62
- }
75
+ const runtimeConfig = { ...config, server: { ...config.server, host, port } };
76
+ const server = await services.createServer(runtimeConfig, { files });
63
77
 
64
- const options = parseOptions(args.slice(4), ['--host', '-h', '--port', '-p']);
65
- const host = options.host ?? process.env.HOST ?? DEFAULT_HOST;
66
- const port = Number(options.port ?? process.env.PORT ?? DEFAULT_PORT);
78
+ if (openapiMode !== 'none') {
79
+ await server.openapi();
80
+ process.stdout.write(`OpenAPI-схема сохранена в ${config.openapi.path}\n`);
81
+ }
67
82
 
68
- await generateOpenApi({ databasePath, host, outputPath, port, schemaPath });
69
- process.stdout.write(`OpenAPI-схема сохранена в ${outputPath}\n`);
83
+ if (openapiMode === 'only') {
70
84
  return;
71
85
  }
72
86
 
73
- const options = parseOptions(args.slice(1), ['--host', '-h', '--port', '-p', '--schema']);
74
- const host = options.host ?? process.env.HOST ?? DEFAULT_HOST;
75
- const port = Number(options.port ?? process.env.PORT ?? DEFAULT_PORT);
87
+ const fastify = server.fastify();
76
88
 
77
- await startServer({ databasePath, host, port, schemaPath: options.schemaPath });
89
+ await fastify.listen({ host, port });
90
+ fastify.log.info({ database: 'path' in config.database ? config.database.path : 'memory' }, 'Deep JSON Server запущен');
78
91
  }
package/src/config.js ADDED
@@ -0,0 +1,194 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { isObject } from './utils.js';
4
+
5
+ const CONFIG_KEYS = new Set(['database', 'files', 'openapi', 'server']);
6
+ const DATABASE_KEYS = new Set(['data', 'path', 'schema']);
7
+ const FILES_KEYS = new Set(['data', 'directory', 'metadata']);
8
+ const OPENAPI_KEYS = new Set(['path']);
9
+ const SERVER_KEYS = new Set(['host', 'logger', 'maxFileSize', 'maxPageSize', 'port']);
10
+ let configImportIndex = 0;
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, id: string, mimeType: string, name: string }} MemoryFile */
16
+ /** @typedef {{ data: MemoryFile[], directory?: never, metadata?: never } | { data?: never, directory: string, metadata: string }} FilesConfig */
17
+ /**
18
+ * @typedef {object} DeepJsonServerConfig
19
+ * @property {DatabaseConfig} database Database source and optional schema.
20
+ * @property {FilesConfig} [files] Binary-file storage.
21
+ * @property {{ path?: string }} [openapi] Generated OpenAPI file.
22
+ * @property {{ host?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, port?: number }} [server] Runtime settings.
23
+ */
24
+
25
+ const assertKnownKeys = (value, keys, path) => {
26
+ const unknownKey = Object.keys(value).find((key) => !keys.has(key));
27
+
28
+ if (unknownKey != null) {
29
+ throw new Error(`Неизвестный ключ ${path}.${unknownKey}`);
30
+ }
31
+ };
32
+
33
+ const getObject = (value, path, required = false) => {
34
+ if (value == null && !required) {
35
+ return undefined;
36
+ }
37
+
38
+ if (!isObject(value)) {
39
+ throw new Error(`Ключ ${path} должен быть JSON-объектом`);
40
+ }
41
+
42
+ return value;
43
+ };
44
+
45
+ const getString = (value, path, required = false) => {
46
+ if (value == null && !required) {
47
+ return undefined;
48
+ }
49
+
50
+ if (typeof value !== 'string' || value.trim() === '') {
51
+ throw new Error(`Ключ ${path} должен содержать непустую строку`);
52
+ }
53
+
54
+ return value;
55
+ };
56
+
57
+ const resolveConfigPath = (value, directoryPath) => (value == null ? undefined : resolve(directoryPath, value));
58
+
59
+ const normalizeSchema = (schema, directoryPath) => {
60
+ if (schema == null) {
61
+ return undefined;
62
+ }
63
+
64
+ if (typeof schema === 'string') {
65
+ return resolveConfigPath(getString(schema, 'config.database.schema', true), directoryPath);
66
+ }
67
+
68
+ return getObject(schema, 'config.database.schema', true);
69
+ };
70
+
71
+ const normalizeDatabase = (value, directoryPath) => {
72
+ const database = getObject(value, 'config.database', true);
73
+
74
+ assertKnownKeys(database, DATABASE_KEYS, 'config.database');
75
+
76
+ const hasData = database.data != null;
77
+ const hasPath = database.path != null;
78
+
79
+ if (hasData === hasPath) {
80
+ throw new Error('Укажите ровно один из ключей config.database.path и config.database.data');
81
+ }
82
+
83
+ const schema = normalizeSchema(database.schema, directoryPath);
84
+
85
+ if (hasData) {
86
+ return { data: getObject(database.data, 'config.database.data', true), schema };
87
+ }
88
+
89
+ return { path: resolveConfigPath(getString(database.path, 'config.database.path', true), directoryPath), schema };
90
+ };
91
+
92
+ const normalizeFiles = (value, directoryPath) => {
93
+ const files = getObject(value, 'config.files');
94
+
95
+ if (files == null) {
96
+ return undefined;
97
+ }
98
+
99
+ assertKnownKeys(files, FILES_KEYS, 'config.files');
100
+
101
+ const hasData = files.data != null;
102
+ const hasDiskStorage = files.directory != null || files.metadata != null;
103
+
104
+ if (hasData === hasDiskStorage) {
105
+ throw new Error('Укажите либо config.files.data, либо пару config.files.directory и config.files.metadata');
106
+ }
107
+
108
+ if (hasData) {
109
+ if (!Array.isArray(files.data)) {
110
+ throw new Error('Ключ config.files.data должен содержать массив');
111
+ }
112
+
113
+ return { data: files.data };
114
+ }
115
+
116
+ return {
117
+ directory: resolveConfigPath(getString(files.directory, 'config.files.directory', true), directoryPath),
118
+ metadata: resolveConfigPath(getString(files.metadata, 'config.files.metadata', true), directoryPath),
119
+ };
120
+ };
121
+
122
+ /**
123
+ * @param {DeepJsonServerConfig} config Server configuration.
124
+ * @param {string} [directoryPath] Base directory for relative paths.
125
+ * @returns {{ database: DatabaseConfig, files?: FilesConfig, openapi: { path?: string }, server: { host?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, port?: number } }} Normalized configuration.
126
+ */
127
+ export const normalizeServerConfig = (config, directoryPath = '.') => {
128
+ if (!isObject(config)) {
129
+ throw new Error('Конфигурация сервера должна содержать JSON-объект');
130
+ }
131
+
132
+ assertKnownKeys(config, CONFIG_KEYS, 'config');
133
+
134
+ const database = normalizeDatabase(config.database, directoryPath);
135
+ const files = normalizeFiles(config.files, directoryPath);
136
+ const openapi = getObject(config.openapi, 'config.openapi') ?? {};
137
+ const server = getObject(config.server, 'config.server') ?? {};
138
+
139
+ assertKnownKeys(openapi, OPENAPI_KEYS, 'config.openapi');
140
+ assertKnownKeys(server, SERVER_KEYS, 'config.server');
141
+
142
+ const openapiPath = getString(openapi.path, 'config.openapi.path');
143
+ const host = getString(server.host, 'config.server.host');
144
+
145
+ if (server.port != null && (!Number.isInteger(server.port) || server.port < 0 || server.port > 65_535)) {
146
+ throw new Error('Ключ config.server.port должен быть целым числом от 0 до 65535');
147
+ }
148
+
149
+ if (server.maxPageSize != null && (!Number.isInteger(server.maxPageSize) || server.maxPageSize < 1)) {
150
+ throw new Error('Ключ config.server.maxPageSize должен быть положительным целым числом');
151
+ }
152
+
153
+ if (server.maxFileSize != null && (!Number.isInteger(server.maxFileSize) || server.maxFileSize < 1)) {
154
+ throw new Error('Ключ config.server.maxFileSize должен быть положительным целым числом');
155
+ }
156
+
157
+ if (server.logger != null && typeof server.logger !== 'boolean' && !isObject(server.logger)) {
158
+ throw new Error('Ключ config.server.logger должен содержать boolean или JSON-объект');
159
+ }
160
+
161
+ return {
162
+ database,
163
+ files,
164
+ openapi: { path: resolveConfigPath(openapiPath, directoryPath) },
165
+ server: {
166
+ host,
167
+ logger: server.logger,
168
+ maxFileSize: server.maxFileSize,
169
+ maxPageSize: server.maxPageSize,
170
+ port: server.port,
171
+ },
172
+ };
173
+ };
174
+
175
+ /** @param {string} configPath Configuration module path. */
176
+ export async function readServerConfig(configPath) {
177
+ const resolvedConfigPath = resolve(getString(configPath, 'config', true));
178
+ let config;
179
+
180
+ try {
181
+ const configUrl = pathToFileURL(resolvedConfigPath);
182
+
183
+ configUrl.searchParams.set('deep-json-server-import', String(configImportIndex++));
184
+ config = (await import(configUrl.href)).default;
185
+ } catch (error) {
186
+ throw new Error(`Не удалось загрузить конфигурацию ${resolvedConfigPath}: ${error.message}`, { cause: error });
187
+ }
188
+
189
+ if (!isObject(config)) {
190
+ throw new Error('Конфигурация сервера должна экспортировать JSON-объект через export default');
191
+ }
192
+
193
+ return normalizeServerConfig(config, dirname(resolvedConfigPath));
194
+ }
package/src/constants.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export const DEFAULT_HOST = '127.0.0.1';
2
+ export const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024;
2
3
  export const DEFAULT_PAGE_SIZE = 10;
3
4
  export const DEFAULT_PORT = 4001;
4
5
  export const MAX_PAGE_SIZE = 1000;
package/src/database.js CHANGED
@@ -72,8 +72,7 @@ export const readJsonObject = async (path, label) => {
72
72
 
73
73
  export const readDatabaseFile = async (databasePath) => validateDatabase(await readJsonObject(databasePath, 'Файл базы данных'));
74
74
 
75
- /** @returns {Promise<any>} Internal LowDB-backed store. */
76
- export const createDatabaseStore = async (databasePath) => {
75
+ const createDiskDatabaseStore = async (databasePath) => {
77
76
  const resolvedDatabasePath = resolveDatabasePath(databasePath);
78
77
  const initialData = await readDatabaseFile(resolvedDatabasePath);
79
78
  const database = await JSONFilePreset(resolvedDatabasePath, initialData);
@@ -105,6 +104,36 @@ export const createDatabaseStore = async (databasePath) => {
105
104
  return { database, path: resolvedDatabasePath, read, update };
106
105
  };
107
106
 
107
+ const createMemoryDatabaseStore = (sourceData) => {
108
+ const database = { data: validateDatabase(structuredClone(sourceData)) };
109
+ let updateQueue = Promise.resolve();
110
+
111
+ const read = async () => database.data;
112
+ const update = (operation) => {
113
+ const pendingOperation = updateQueue.then(() => {
114
+ const draft = { data: structuredClone(database.data) };
115
+ const result = operation(draft);
116
+
117
+ validateDatabase(draft.data);
118
+ database.data = draft.data;
119
+
120
+ return result;
121
+ });
122
+
123
+ updateQueue = pendingOperation.catch(() => undefined);
124
+
125
+ return pendingOperation;
126
+ };
127
+
128
+ return { database, read, update };
129
+ };
130
+
131
+ /**
132
+ * @param {import('./config.js').DatabaseConfig} config Database source.
133
+ * @returns {Promise<any>} Internal database store.
134
+ */
135
+ export const createDatabaseStore = async (config) => ('data' in config ? createMemoryDatabaseStore(config.data) : createDiskDatabaseStore(config.path));
136
+
108
137
  export const getCollection = (database, resource) => {
109
138
  const collection = isSafeKey(resource) ? database.data[resource] : undefined;
110
139