@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/README.md +196 -109
- package/README.ru.md +196 -109
- package/index.js +7 -2
- package/package.json +1 -1
- package/src/cli.js +45 -44
- package/src/config.js +132 -35
- package/src/database.js +31 -2
- package/src/files.js +98 -8
- package/src/openapi/config.js +15 -1
- package/src/openapi/document.js +19 -33
- package/src/openapi/index.js +29 -18
- package/src/query/pagination.js +1 -7
- package/src/server.js +87 -77
- package/types/index.d.ts +11 -2
- package/types/src/config.d.ts +86 -0
- package/types/src/database.d.ts +5 -2
- package/types/src/files.d.ts +23 -4
- package/types/src/openapi/config.d.ts +1 -1
- package/types/src/openapi/document.d.ts +6 -7
- package/types/src/openapi/index.d.ts +9 -14
- package/types/src/query/pagination.d.ts +1 -6
- package/types/src/server.d.ts +11 -28
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 {
|
|
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 [--
|
|
9
|
+
deep-json-server [--files] [--openapi | --openapi-only] <server.config.js>
|
|
11
10
|
|
|
12
11
|
Параметры:
|
|
13
|
-
--
|
|
14
|
-
--
|
|
15
|
-
--
|
|
12
|
+
--files Добавить файловые маршруты в сервер и OpenAPI
|
|
13
|
+
--openapi Сгенерировать OpenAPI и запустить сервер
|
|
14
|
+
--openapi-only Сгенерировать OpenAPI и завершить работу
|
|
15
|
+
--help Показать справку`;
|
|
16
16
|
|
|
17
17
|
const parseArguments = (args) => {
|
|
18
|
-
const
|
|
18
|
+
const options = { files: false, openapiMode: 'none' };
|
|
19
19
|
let configPath;
|
|
20
20
|
|
|
21
21
|
args.forEach((argument) => {
|
|
22
22
|
if (argument === '--files') {
|
|
23
|
-
|
|
23
|
+
options.files = true;
|
|
24
24
|
} else if (argument === '--openapi') {
|
|
25
|
-
|
|
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, ...
|
|
49
|
+
return { configPath, ...options };
|
|
40
50
|
};
|
|
41
51
|
|
|
42
|
-
const validateModeConfig = (config, { files,
|
|
43
|
-
if (
|
|
44
|
-
throw new Error(
|
|
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.
|
|
52
|
-
throw new Error('Для --files укажите
|
|
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 = {
|
|
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,
|
|
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,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
const normalizeSchema = (schema, directoryPath) => {
|
|
60
|
+
if (schema == null) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
if (typeof schema === 'string') {
|
|
65
|
+
return resolveConfigPath(getString(schema, 'config.database.schema', true), directoryPath);
|
|
66
|
+
}
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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('Конфигурация сервера должна
|
|
129
|
+
throw new Error('Конфигурация сервера должна содержать JSON-объект');
|
|
61
130
|
}
|
|
62
131
|
|
|
63
132
|
assertKnownKeys(config, CONFIG_KEYS, 'config');
|
|
64
133
|
|
|
65
|
-
const database =
|
|
66
|
-
const 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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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,
|
|
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(
|
|
362
|
+
return reply.send(stream);
|
|
273
363
|
});
|
|
274
364
|
|
|
275
365
|
fileServer.delete('/_files/:id', async (request) => store.remove(request.params.id));
|
package/src/openapi/config.js
CHANGED
|
@@ -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
|
|
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
|
+
};
|
package/src/openapi/document.js
CHANGED
|
@@ -1,26 +1,10 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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
|
-
*
|
|
254
|
-
* @param {Record<string, Array<Record<string, unknown
|
|
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
|
|
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
|
-
|
|
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', '
|
|
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
|
}
|