@kollors/deep-json-server 0.5.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,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 server = await services.createServer(runtimeConfig, { files });
77
+
78
+ if (openapiMode !== 'none') {
79
+ await server.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 = server.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,25 @@ 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, 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
+
12
25
  const assertKnownKeys = (value, keys, path) => {
13
26
  const unknownKey = Object.keys(value).find((key) => !keys.has(key));
14
27
 
@@ -17,9 +30,9 @@ const assertKnownKeys = (value, keys, path) => {
17
30
  }
18
31
  };
19
32
 
20
- const getObject = (value, path) => {
21
- if (value == null) {
22
- return {};
33
+ const getObject = (value, path, required = false) => {
34
+ if (value == null && !required) {
35
+ return undefined;
23
36
  }
24
37
 
25
38
  if (!isObject(value)) {
@@ -43,55 +56,139 @@ const getString = (value, path, required = false) => {
43
56
 
44
57
  const resolveConfigPath = (value, directoryPath) => (value == null ? undefined : resolve(directoryPath, value));
45
58
 
46
- export async function readServerConfig(configPath) {
47
- const resolvedConfigPath = resolve(getString(configPath, 'config', true));
48
- let config;
59
+ const normalizeSchema = (schema, directoryPath) => {
60
+ if (schema == null) {
61
+ return undefined;
62
+ }
49
63
 
50
- try {
51
- const configUrl = pathToFileURL(resolvedConfigPath);
64
+ if (typeof schema === 'string') {
65
+ return resolveConfigPath(getString(schema, 'config.database.schema', true), directoryPath);
66
+ }
52
67
 
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 });
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');
57
106
  }
58
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 = '.') => {
59
128
  if (!isObject(config)) {
60
- throw new Error('Конфигурация сервера должна экспортировать JSON-объект через export default');
129
+ throw new Error('Конфигурация сервера должна содержать JSON-объект');
61
130
  }
62
131
 
63
132
  assertKnownKeys(config, CONFIG_KEYS, 'config');
64
133
 
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');
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') ?? {};
69
138
 
70
- assertKnownKeys(database, DATABASE_KEYS, 'config.database');
71
- assertKnownKeys(files, FILES_KEYS, 'config.files');
72
139
  assertKnownKeys(openapi, OPENAPI_KEYS, 'config.openapi');
73
140
  assertKnownKeys(server, SERVER_KEYS, 'config.server');
74
141
 
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
142
  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
143
  const host = getString(server.host, 'config.server.host');
82
144
 
83
145
  if (server.port != null && (!Number.isInteger(server.port) || server.port < 0 || server.port > 65_535)) {
84
146
  throw new Error('Ключ config.server.port должен быть целым числом от 0 до 65535');
85
147
  }
86
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
+
87
161
  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),
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
+ },
96
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));
97
194
  }
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
 
package/src/files.js CHANGED
@@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { createReadStream, createWriteStream } from 'node:fs';
3
3
  import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
- import { Transform } from 'node:stream';
5
+ import { Readable, Transform } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { createHttpError, isObject } from './utils.js';
8
8
 
@@ -123,7 +123,7 @@ const createSizeLimiter = (maxFileSize, onSize) => {
123
123
  });
124
124
  };
125
125
 
126
- const createFileStore = async ({ directoryPath: sourceDirectoryPath, metadataPath: sourceMetadataPath }) => {
126
+ const createDiskFileStore = async ({ directory: sourceDirectoryPath, metadata: sourceMetadataPath }) => {
127
127
  if (typeof sourceDirectoryPath !== 'string' || sourceDirectoryPath.trim() === '') {
128
128
  throw new Error('Путь к директории файлов не должен быть пустым');
129
129
  }
@@ -168,7 +168,7 @@ const createFileStore = async ({ directoryPath: sourceDirectoryPath, metadataPat
168
168
  throw error;
169
169
  }
170
170
 
171
- return { file, path };
171
+ return { file, stream: createReadStream(path) };
172
172
  });
173
173
 
174
174
  const upload = ({ maxFileSize, mimeType, name, stream }) =>
@@ -237,11 +237,101 @@ const createFileStore = async ({ directoryPath: sourceDirectoryPath, metadataPat
237
237
  return { get, remove, upload };
238
238
  };
239
239
 
240
- const getDownloadName = (name) => encodeURIComponent(basename(name)).replaceAll("'", '%27');
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);
241
273
 
242
- export const registerFileRoutes = async (server, { directoryPath, maxFileSize, metadataPath }) => {
243
- const store = await createFileStore({ directoryPath, metadataPath });
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');
244
333
 
334
+ export const registerFileRoutes = (server, { maxFileSize, store }) => {
245
335
  server.register((fileServer, _options, done) => {
246
336
  fileServer.removeAllContentTypeParsers();
247
337
  fileServer.addContentTypeParser('*', (_request, payload, parserDone) => parserDone(null, payload));
@@ -264,12 +354,12 @@ export const registerFileRoutes = async (server, { directoryPath, maxFileSize, m
264
354
  });
265
355
 
266
356
  fileServer.get('/_files/:id', async (request, reply) => {
267
- const { file, path } = await store.get(request.params.id);
357
+ const { file, stream } = await store.get(request.params.id);
268
358
 
269
359
  reply.header('Content-Disposition', `inline; filename*=UTF-8''${getDownloadName(file.name)}`);
270
360
  reply.type(file.mimeType);
271
361
 
272
- return reply.send(createReadStream(path));
362
+ return reply.send(stream);
273
363
  });
274
364
 
275
365
  fileServer.delete('/_files/:id', async (request) => store.remove(request.params.id));
@@ -107,4 +107,18 @@ export const applyConfiguredFields = (schema, resource, resourceConfig) => {
107
107
  return applyRequiredFields(schemaWithFormats, '', new Set(requiredFields));
108
108
  };
109
109
 
110
- export const readSchemaConfig = async (schemaPath) => (schemaPath == null ? {} : readJsonObject(schemaPath, 'Файл схемы базы данных'));
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
+ };
@@ -1,26 +1,10 @@
1
- import { DEFAULT_HOST, DEFAULT_PAGE_SIZE, DEFAULT_PORT, MAX_PAGE_SIZE } from '../constants.js';
1
+ import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js';
2
2
  import { validateDatabase } from '../database.js';
3
3
  import { getRelationMetadata } from '../relation-metadata.js';
4
4
  import { getResourceNames, isObject, singularize, toPascalCase } from '../utils.js';
5
5
  import { applyConfiguredFields, validateSchemaConfig } from './config.js';
6
6
  import { ensureGeneratedIdSchema, inferObjectSchema, mergeSchemaOverrides, omitId } from './inference.js';
7
7
 
8
- const getServerUrl = (host, port) => {
9
- const serverPort = Number(port);
10
-
11
- if (typeof host !== 'string' || host === '') {
12
- throw new Error('Адрес сервера не должен быть пустым');
13
- }
14
-
15
- if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65_535) {
16
- throw new Error('Порт должен быть целым числом от 1 до 65535');
17
- }
18
-
19
- const serverHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
20
-
21
- return `http://${serverHost}:${serverPort}`;
22
- };
23
-
24
8
  const createSchemaReference = (name) => ({ $ref: `#/components/schemas/${name}` });
25
9
 
26
10
  const addForwardRelations = (schema, resources, componentNames, sourceResource) => {
@@ -100,12 +84,12 @@ const addReverseRelations = (schemas, rawSchemas, resources, componentNames) =>
100
84
  });
101
85
  };
102
86
 
103
- const createParameters = () => ({
87
+ const createParameters = (maxPageSize) => ({
104
88
  ContentName: { description: 'URI-encoded relative file name', in: 'header', name: 'Content-Name', required: true, schema: { type: 'string' } },
105
89
  Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
106
90
  Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
107
91
  Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
108
- PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum: MAX_PAGE_SIZE, minimum: 1, type: 'integer' } },
92
+ PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum: maxPageSize, minimum: 1, type: 'integer' } },
109
93
  Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
110
94
  Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
111
95
  });
@@ -250,15 +234,23 @@ const validateGeneratedNames = (resources, componentNames, files) => {
250
234
  };
251
235
 
252
236
  /**
253
- * Creates an OpenAPI 3.0 document from a database and optional schema configuration.
254
- * @param {Record<string, Array<Record<string, unknown>>>} database Database contents.
255
- * @param {Record<string, unknown>} [schemaConfig] Schema configuration.
256
- * @param {{ files?: boolean, host?: string, port?: number }} [serverOptions] Server address and optional features used in the generated document.
237
+ * Builds an OpenAPI 3.0 document from resolved server data.
238
+ * @param {{ database: Record<string, Array<Record<string, unknown>>>, files?: boolean, maxPageSize?: number, schema?: Record<string, unknown> }} options Document options.
257
239
  * @returns {Record<string, unknown>} OpenAPI document.
258
240
  */
259
- export function createOpenApiDocument(database, schemaConfig = {}, { files = false, host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
241
+ export function buildOpenapiDocument(options) {
242
+ const { database, files = false, maxPageSize = MAX_PAGE_SIZE, schema: schemaConfig = {} } = options ?? {};
243
+
244
+ if (typeof files !== 'boolean') {
245
+ throw new Error('Ключ files должен содержать boolean');
246
+ }
247
+
260
248
  validateDatabase(database);
261
249
 
250
+ if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
251
+ throw new Error('Максимальный размер страницы должен быть положительным целым числом');
252
+ }
253
+
262
254
  if (!isObject(schemaConfig)) {
263
255
  throw new Error('Схема базы данных должна содержать JSON-объект');
264
256
  }
@@ -306,14 +298,9 @@ export function createOpenApiDocument(database, schemaConfig = {}, { files = fal
306
298
  schemas[`${componentName}Page`] = {
307
299
  properties: {
308
300
  data: { items: createSchemaReference(componentName), type: 'array' },
309
- first: { type: 'integer' },
310
- items: { type: 'integer' },
311
- last: { type: 'integer' },
312
- next: { nullable: true, type: 'integer' },
313
- pages: { type: 'integer' },
314
- prev: { nullable: true, type: 'integer' },
301
+ total: { minimum: 0, type: 'integer' },
315
302
  },
316
- required: ['data', 'first', 'items', 'last', 'next', 'pages', 'prev'],
303
+ required: ['data', 'total'],
317
304
  type: 'object',
318
305
  };
319
306
  });
@@ -321,11 +308,10 @@ export function createOpenApiDocument(database, schemaConfig = {}, { files = fal
321
308
  addReverseRelations(schemas, rawSchemas, resources, componentNames);
322
309
 
323
310
  return {
324
- components: { parameters: createParameters(), schemas },
311
+ components: { parameters: createParameters(maxPageSize), schemas },
325
312
  info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
326
313
  openapi: '3.0.3',
327
314
  paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource])), files ? createFilePaths() : {}),
328
- servers: [{ url: getServerUrl(host, port) }],
329
315
  tags: [...resources.map((resource) => ({ name: resource })), ...(files ? [{ name: 'files' }] : [])],
330
316
  };
331
317
  }