@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/files.js ADDED
@@ -0,0 +1,368 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { Readable, Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { createHttpError, isObject } from './utils.js';
8
+
9
+ const createFileId = (files) => {
10
+ let id;
11
+
12
+ do {
13
+ id = randomBytes(8).toString('base64url');
14
+ } while (files.some((file) => file.id === id));
15
+
16
+ return id;
17
+ };
18
+
19
+ const validateFileMetadata = (files, metadataPath) => {
20
+ if (!Array.isArray(files)) {
21
+ throw new Error(`Файл метаданных ${metadataPath} должен содержать JSON-массив`);
22
+ }
23
+
24
+ const ids = new Set();
25
+
26
+ files.forEach((file, index) => {
27
+ if (
28
+ !isObject(file) ||
29
+ typeof file.id !== 'string' ||
30
+ file.id === '' ||
31
+ typeof file.mimeType !== 'string' ||
32
+ file.mimeType === '' ||
33
+ typeof file.name !== 'string' ||
34
+ file.name === '' ||
35
+ !Number.isInteger(file.size) ||
36
+ file.size < 0 ||
37
+ file.url !== `/_files/${file.id}`
38
+ ) {
39
+ throw new Error(`Некорректная запись ${index} в файле метаданных ${metadataPath}`);
40
+ }
41
+
42
+ if (ids.has(file.id)) {
43
+ throw new Error(`Файл метаданных ${metadataPath} содержит повторяющийся id «${file.id}»`);
44
+ }
45
+
46
+ ids.add(file.id);
47
+ });
48
+
49
+ return files;
50
+ };
51
+
52
+ const readMetadata = async (metadataPath) => {
53
+ try {
54
+ return validateFileMetadata(JSON.parse(await readFile(metadataPath, 'utf8')), metadataPath);
55
+ } catch (error) {
56
+ if (error?.code === 'ENOENT') {
57
+ return [];
58
+ }
59
+
60
+ throw error;
61
+ }
62
+ };
63
+
64
+ const writeMetadata = async (metadataPath, files) => {
65
+ const temporaryPath = `${metadataPath}.${randomBytes(6).toString('hex')}.tmp`;
66
+
67
+ try {
68
+ await writeFile(temporaryPath, JSON.stringify(files, null, 2), { encoding: 'utf8', flag: 'wx' });
69
+ await rename(temporaryPath, metadataPath);
70
+ } catch (error) {
71
+ await rm(temporaryPath, { force: true });
72
+ throw error;
73
+ }
74
+ };
75
+
76
+ const getContentName = (value) => {
77
+ if (typeof value !== 'string' || value === '') {
78
+ throw createHttpError(400, 'Заголовок Content-Name обязателен');
79
+ }
80
+
81
+ let name;
82
+
83
+ try {
84
+ name = decodeURIComponent(value).replaceAll('\\', '/');
85
+ } catch {
86
+ throw createHttpError(400, 'Заголовок Content-Name содержит некорректное значение');
87
+ }
88
+
89
+ const parts = name.split('/');
90
+
91
+ if (name.startsWith('/') || parts.some((part) => part === '' || part === '.' || part === '..' || part.includes('\0'))) {
92
+ throw createHttpError(400, 'Заголовок Content-Name должен содержать безопасный относительный путь');
93
+ }
94
+
95
+ return name;
96
+ };
97
+
98
+ const getMimeType = (value) => {
99
+ const mimeType = typeof value === 'string' ? value.split(';', 1)[0].trim().toLowerCase() : '';
100
+
101
+ if (mimeType === '' || !/^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/.test(mimeType)) {
102
+ throw createHttpError(400, 'Заголовок Content-Type должен содержать корректный MIME-тип');
103
+ }
104
+
105
+ return mimeType;
106
+ };
107
+
108
+ const createSizeLimiter = (maxFileSize, onSize) => {
109
+ let size = 0;
110
+
111
+ return new Transform({
112
+ transform(chunk, _encoding, callback) {
113
+ size += chunk.length;
114
+
115
+ if (size > maxFileSize) {
116
+ callback(createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`));
117
+ return;
118
+ }
119
+
120
+ onSize(size);
121
+ callback(null, chunk);
122
+ },
123
+ });
124
+ };
125
+
126
+ const createDiskFileStore = async ({ directory: sourceDirectoryPath, metadata: sourceMetadataPath }) => {
127
+ if (typeof sourceDirectoryPath !== 'string' || sourceDirectoryPath.trim() === '') {
128
+ throw new Error('Путь к директории файлов не должен быть пустым');
129
+ }
130
+
131
+ if (typeof sourceMetadataPath !== 'string' || sourceMetadataPath.trim() === '') {
132
+ throw new Error('Путь к файлу метаданных не должен быть пустым');
133
+ }
134
+
135
+ const directoryPath = resolve(sourceDirectoryPath);
136
+ const metadataPath = resolve(sourceMetadataPath);
137
+ let operationQueue = Promise.resolve();
138
+
139
+ await Promise.all([mkdir(directoryPath, { recursive: true }), mkdir(dirname(metadataPath), { recursive: true })]);
140
+ await readMetadata(metadataPath);
141
+
142
+ const schedule = (operation) => {
143
+ const pendingOperation = operationQueue.then(operation);
144
+
145
+ operationQueue = pendingOperation.catch(() => undefined);
146
+
147
+ return pendingOperation;
148
+ };
149
+
150
+ const get = (id) =>
151
+ schedule(async () => {
152
+ const files = await readMetadata(metadataPath);
153
+ const file = files.find((item) => item.id === id);
154
+
155
+ if (file == null) {
156
+ throw createHttpError(404, 'Файл не найден');
157
+ }
158
+
159
+ const path = join(directoryPath, file.id);
160
+
161
+ try {
162
+ await access(path);
163
+ } catch (error) {
164
+ if (error?.code === 'ENOENT') {
165
+ throw createHttpError(404, 'Файл не найден');
166
+ }
167
+
168
+ throw error;
169
+ }
170
+
171
+ return { file, stream: createReadStream(path) };
172
+ });
173
+
174
+ const upload = ({ maxFileSize, mimeType, name, stream }) =>
175
+ schedule(async () => {
176
+ const files = await readMetadata(metadataPath);
177
+ const id = createFileId(files);
178
+ const path = join(directoryPath, id);
179
+ const temporaryPath = `${path}.upload`;
180
+ let size = 0;
181
+
182
+ try {
183
+ await pipeline(
184
+ stream,
185
+ createSizeLimiter(maxFileSize, (value) => (size = value)),
186
+ createWriteStream(temporaryPath, { flags: 'wx' }),
187
+ );
188
+ await rename(temporaryPath, path);
189
+
190
+ const file = { id, mimeType, name, size, url: `/_files/${id}` };
191
+
192
+ files.push(file);
193
+ await writeMetadata(metadataPath, files);
194
+
195
+ return file;
196
+ } catch (error) {
197
+ await Promise.all([rm(temporaryPath, { force: true }), rm(path, { force: true })]);
198
+ throw error;
199
+ }
200
+ });
201
+
202
+ const remove = (id) =>
203
+ schedule(async () => {
204
+ const files = await readMetadata(metadataPath);
205
+ const index = files.findIndex((file) => file.id === id);
206
+
207
+ if (index === -1) {
208
+ throw createHttpError(404, 'Файл не найден');
209
+ }
210
+
211
+ const [file] = files.splice(index, 1);
212
+ const path = join(directoryPath, file.id);
213
+ const temporaryPath = `${path}.${randomBytes(6).toString('hex')}.delete`;
214
+
215
+ try {
216
+ await rename(path, temporaryPath);
217
+ } catch (error) {
218
+ if (error?.code === 'ENOENT') {
219
+ throw createHttpError(404, 'Файл не найден');
220
+ }
221
+
222
+ throw error;
223
+ }
224
+
225
+ try {
226
+ await writeMetadata(metadataPath, files);
227
+ } catch (error) {
228
+ await rename(temporaryPath, path);
229
+ throw error;
230
+ }
231
+
232
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
233
+
234
+ return file;
235
+ });
236
+
237
+ return { get, remove, upload };
238
+ };
239
+
240
+ const createMemoryFileStore = (sourceFiles) => {
241
+ const ids = new Set();
242
+ const storedFiles = sourceFiles.map((sourceFile, index) => {
243
+ if (
244
+ !isObject(sourceFile) ||
245
+ typeof sourceFile.id !== 'string' ||
246
+ sourceFile.id === '' ||
247
+ typeof sourceFile.mimeType !== 'string' ||
248
+ sourceFile.mimeType === '' ||
249
+ typeof sourceFile.name !== 'string' ||
250
+ sourceFile.name === '' ||
251
+ !(sourceFile.content instanceof Uint8Array)
252
+ ) {
253
+ throw new Error(`Некорректная запись ${index} в config.files.data`);
254
+ }
255
+
256
+ if (ids.has(sourceFile.id)) {
257
+ throw new Error(`config.files.data содержит повторяющийся id «${sourceFile.id}»`);
258
+ }
259
+
260
+ ids.add(sourceFile.id);
261
+
262
+ const content = Buffer.from(sourceFile.content);
263
+
264
+ return {
265
+ content,
266
+ file: { id: sourceFile.id, mimeType: sourceFile.mimeType, name: sourceFile.name, size: content.length, url: `/_files/${sourceFile.id}` },
267
+ };
268
+ });
269
+ let operationQueue = Promise.resolve();
270
+
271
+ const schedule = (operation) => {
272
+ const pendingOperation = operationQueue.then(operation);
273
+
274
+ operationQueue = pendingOperation.catch(() => undefined);
275
+
276
+ return pendingOperation;
277
+ };
278
+
279
+ const get = (id) =>
280
+ schedule(async () => {
281
+ const storedFile = storedFiles.find(({ file }) => file.id === id);
282
+
283
+ if (storedFile == null) {
284
+ throw createHttpError(404, 'Файл не найден');
285
+ }
286
+
287
+ return { file: storedFile.file, stream: Readable.from([storedFile.content]) };
288
+ });
289
+
290
+ const upload = ({ maxFileSize, mimeType, name, stream }) =>
291
+ schedule(async () => {
292
+ const chunks = [];
293
+ let size = 0;
294
+
295
+ for await (const chunk of stream) {
296
+ const buffer = Buffer.from(chunk);
297
+
298
+ size += buffer.length;
299
+
300
+ if (size > maxFileSize) {
301
+ throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
302
+ }
303
+
304
+ chunks.push(buffer);
305
+ }
306
+
307
+ const id = createFileId(storedFiles.map(({ file }) => file));
308
+ const file = { id, mimeType, name, size, url: `/_files/${id}` };
309
+
310
+ storedFiles.push({ content: Buffer.concat(chunks), file });
311
+
312
+ return file;
313
+ });
314
+
315
+ const remove = (id) =>
316
+ schedule(async () => {
317
+ const index = storedFiles.findIndex(({ file }) => file.id === id);
318
+
319
+ if (index === -1) {
320
+ throw createHttpError(404, 'Файл не найден');
321
+ }
322
+
323
+ return storedFiles.splice(index, 1)[0].file;
324
+ });
325
+
326
+ return { get, remove, upload };
327
+ };
328
+
329
+ /** @param {import('./config.js').FilesConfig} config File storage configuration. */
330
+ export const createFileStore = async (config) => ('data' in config ? createMemoryFileStore(config.data) : createDiskFileStore(config));
331
+
332
+ const getDownloadName = (name) => encodeURIComponent(basename(name)).replaceAll("'", '%27');
333
+
334
+ export const registerFileRoutes = (server, { maxFileSize, store }) => {
335
+ server.register((fileServer, _options, done) => {
336
+ fileServer.removeAllContentTypeParsers();
337
+ fileServer.addContentTypeParser('*', (_request, payload, parserDone) => parserDone(null, payload));
338
+
339
+ fileServer.post('/_files', async (request, reply) => {
340
+ const contentLength = Number(request.headers['content-length']);
341
+
342
+ if (Number.isFinite(contentLength) && contentLength > maxFileSize) {
343
+ throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
344
+ }
345
+
346
+ const file = await store.upload({
347
+ maxFileSize,
348
+ mimeType: getMimeType(request.headers['content-type']),
349
+ name: getContentName(request.headers['content-name']),
350
+ stream: request.body,
351
+ });
352
+
353
+ return reply.code(201).send(file);
354
+ });
355
+
356
+ fileServer.get('/_files/:id', async (request, reply) => {
357
+ const { file, stream } = await store.get(request.params.id);
358
+
359
+ reply.header('Content-Disposition', `inline; filename*=UTF-8''${getDownloadName(file.name)}`);
360
+ reply.type(file.mimeType);
361
+
362
+ return reply.send(stream);
363
+ });
364
+
365
+ fileServer.delete('/_files/:id', async (request) => store.remove(request.params.id));
366
+ done();
367
+ });
368
+ };
@@ -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,11 +84,12 @@ const addReverseRelations = (schemas, rawSchemas, resources, componentNames) =>
100
84
  });
101
85
  };
102
86
 
103
- const createParameters = () => ({
87
+ const createParameters = (maxPageSize) => ({
88
+ ContentName: { description: 'URI-encoded relative file name', in: 'header', name: 'Content-Name', required: true, schema: { type: 'string' } },
104
89
  Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
105
90
  Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
106
91
  Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
107
- 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' } },
108
93
  Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
109
94
  Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
110
95
  });
@@ -114,6 +99,40 @@ const createResponse = (description, schema) => ({ description, ...(schema != nu
114
99
  const createParameterReference = (name) => ({ $ref: `#/components/parameters/${name}` });
115
100
  const createRequestBody = (name) => ({ required: true, ...createJsonContent(createSchemaReference(name)) });
116
101
 
102
+ const createFilePaths = () => ({
103
+ '/_files': {
104
+ post: {
105
+ operationId: 'uploadFile',
106
+ parameters: [createParameterReference('ContentName')],
107
+ requestBody: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, required: true },
108
+ responses: {
109
+ 201: createResponse('Uploaded', createSchemaReference('UploadedFile')),
110
+ 400: createResponse('Invalid request', createSchemaReference('Error')),
111
+ 413: createResponse('File is too large', createSchemaReference('Error')),
112
+ 415: createResponse('Unsupported media type', createSchemaReference('Error')),
113
+ },
114
+ tags: ['files'],
115
+ },
116
+ },
117
+ '/_files/{id}': {
118
+ delete: {
119
+ operationId: 'deleteFileById',
120
+ parameters: [createParameterReference('Id')],
121
+ responses: { 200: createResponse('Deleted', createSchemaReference('UploadedFile')), 404: createResponse('Not found', createSchemaReference('Error')) },
122
+ tags: ['files'],
123
+ },
124
+ get: {
125
+ operationId: 'getFileById',
126
+ parameters: [createParameterReference('Id')],
127
+ responses: {
128
+ 200: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, description: 'File contents' },
129
+ 404: createResponse('Not found', createSchemaReference('Error')),
130
+ },
131
+ tags: ['files'],
132
+ },
133
+ },
134
+ });
135
+
117
136
  const createResourcePaths = (resource, componentName) => {
118
137
  const resourceName = toPascalCase(resource);
119
138
 
@@ -181,8 +200,8 @@ const getOperationIds = (resource) => {
181
200
  return [`get${resourceName}`, `post${resourceName}`, `delete${resourceName}ById`, `get${resourceName}ById`, `patch${resourceName}ById`, `put${resourceName}ById`];
182
201
  };
183
202
 
184
- const validateGeneratedNames = (resources, componentNames) => {
185
- const schemaOwners = new Map([['Error', 'встроенная схема ошибки']]);
203
+ const validateGeneratedNames = (resources, componentNames, files) => {
204
+ const schemaOwners = new Map([['Error', 'встроенная схема ошибки'], ...(files ? [['UploadedFile', 'встроенная схема файла']] : [])]);
186
205
  const operationOwners = new Map();
187
206
 
188
207
  resources.forEach((resource) => {
@@ -215,15 +234,23 @@ const validateGeneratedNames = (resources, componentNames) => {
215
234
  };
216
235
 
217
236
  /**
218
- * Creates an OpenAPI 3.0 document from a database and optional schema configuration.
219
- * @param {Record<string, Array<Record<string, unknown>>>} database Database contents.
220
- * @param {Record<string, unknown>} [schemaConfig] Schema configuration.
221
- * @param {{ host?: string, port?: number }} [serverOptions] Server address 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.
222
239
  * @returns {Record<string, unknown>} OpenAPI document.
223
240
  */
224
- export function createOpenApiDocument(database, schemaConfig = {}, { 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
+
225
248
  validateDatabase(database);
226
249
 
250
+ if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
251
+ throw new Error('Максимальный размер страницы должен быть положительным целым числом');
252
+ }
253
+
227
254
  if (!isObject(schemaConfig)) {
228
255
  throw new Error('Схема базы данных должна содержать JSON-объект');
229
256
  }
@@ -239,7 +266,7 @@ export function createOpenApiDocument(database, schemaConfig = {}, { host = DEFA
239
266
  }),
240
267
  );
241
268
 
242
- validateGeneratedNames(resources, componentNames);
269
+ validateGeneratedNames(resources, componentNames, files);
243
270
 
244
271
  const rawSchemas = Object.fromEntries(
245
272
  resources.map((resource) => {
@@ -250,7 +277,16 @@ export function createOpenApiDocument(database, schemaConfig = {}, { host = DEFA
250
277
  return [resource, applyConfiguredFields(ensureGeneratedIdSchema(configuredSchema), resource, resourceConfig)];
251
278
  }),
252
279
  );
253
- const schemas = { Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' } };
280
+ const schemas = {
281
+ Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
282
+ ...(files && {
283
+ UploadedFile: {
284
+ properties: { id: { type: 'string' }, mimeType: { type: 'string' }, name: { type: 'string' }, size: { minimum: 0, type: 'integer' }, url: { type: 'string' } },
285
+ required: ['id', 'mimeType', 'name', 'size', 'url'],
286
+ type: 'object',
287
+ },
288
+ }),
289
+ };
254
290
 
255
291
  resources.forEach((resource) => {
256
292
  const componentName = componentNames[resource];
@@ -262,14 +298,9 @@ export function createOpenApiDocument(database, schemaConfig = {}, { host = DEFA
262
298
  schemas[`${componentName}Page`] = {
263
299
  properties: {
264
300
  data: { items: createSchemaReference(componentName), type: 'array' },
265
- first: { type: 'integer' },
266
- items: { type: 'integer' },
267
- last: { type: 'integer' },
268
- next: { nullable: true, type: 'integer' },
269
- pages: { type: 'integer' },
270
- prev: { nullable: true, type: 'integer' },
301
+ total: { minimum: 0, type: 'integer' },
271
302
  },
272
- required: ['data', 'first', 'items', 'last', 'next', 'pages', 'prev'],
303
+ required: ['data', 'total'],
273
304
  type: 'object',
274
305
  };
275
306
  });
@@ -277,11 +308,10 @@ export function createOpenApiDocument(database, schemaConfig = {}, { host = DEFA
277
308
  addReverseRelations(schemas, rawSchemas, resources, componentNames);
278
309
 
279
310
  return {
280
- components: { parameters: createParameters(), schemas },
311
+ components: { parameters: createParameters(maxPageSize), schemas },
281
312
  info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
282
313
  openapi: '3.0.3',
283
- paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource]))),
284
- servers: [{ url: getServerUrl(host, port) }],
285
- tags: resources.map((resource) => ({ name: resource })),
314
+ paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource])), files ? createFilePaths() : {}),
315
+ tags: [...resources.map((resource) => ({ name: resource })), ...(files ? [{ name: 'files' }] : [])],
286
316
  };
287
317
  }
@@ -1,25 +1,36 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { stringify } from 'yaml';
4
- import { readDatabaseFile } from '../database.js';
5
- import { readSchemaConfig } from './config.js';
6
- import { createOpenApiDocument } from './document.js';
7
-
8
- export { createOpenApiDocument } from './document.js';
9
-
10
- /**
11
- * Generates an OpenAPI YAML file.
12
- * @param {{ databasePath: string, host?: string, outputPath: string, port?: number, schemaPath: string }} options Generation options.
13
- * @returns {Promise<Record<string, unknown>>} Generated OpenAPI document.
14
- */
15
- export async function generateOpenApi({ databasePath, host, outputPath, port, schemaPath }) {
16
- const database = await readDatabaseFile(databasePath);
17
- const schemaConfig = await readSchemaConfig(schemaPath);
18
- const document = createOpenApiDocument(database, schemaConfig, { host, port });
4
+ import { DEFAULT_HOST, DEFAULT_PORT } from '../constants.js';
5
+ import { buildOpenapiDocument } from './document.js';
6
+
7
+ const getServerUrl = (host, port) => {
8
+ const serverPort = Number(port);
9
+
10
+ if (typeof host !== 'string' || host === '') {
11
+ throw new Error('Адрес сервера не должен быть пустым');
12
+ }
13
+
14
+ if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65_535) {
15
+ throw new Error('Порт должен быть целым числом от 1 до 65535');
16
+ }
17
+
18
+ const serverHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
19
+
20
+ return `http://${serverHost}:${serverPort}`;
21
+ };
22
+
23
+ export const createOpenapi = ({ database, files, host = DEFAULT_HOST, maxPageSize, port = DEFAULT_PORT, schema }) => {
24
+ const document = buildOpenapiDocument({ database, files, maxPageSize, schema });
25
+
26
+ document.servers = [{ url: getServerUrl(host, port) }];
27
+
28
+ return document;
29
+ };
30
+
31
+ export const writeOpenapi = async (document, outputPath) => {
19
32
  const resolvedOutputPath = resolve(outputPath);
20
33
 
21
34
  await mkdir(dirname(resolvedOutputPath), { recursive: true });
22
35
  await writeFile(resolvedOutputPath, stringify(document, { aliasDuplicateObjects: false, lineWidth: 0 }), 'utf8');
23
-
24
- return document;
25
- }
36
+ };
@@ -35,16 +35,10 @@ export const parsePagination = (query, maxPageSize = MAX_PAGE_SIZE) => {
35
35
  };
36
36
 
37
37
  export const paginateItems = (items, page, pageSize) => {
38
- const pages = Math.max(1, Math.ceil(items.length / pageSize));
39
38
  const offset = (page - 1) * pageSize;
40
39
 
41
40
  return {
42
41
  data: items.slice(offset, offset + pageSize),
43
- first: 1,
44
- items: items.length,
45
- last: pages,
46
- next: page < pages ? page + 1 : null,
47
- pages,
48
- prev: page > 1 ? Math.min(page - 1, pages) : null,
42
+ total: items.length,
49
43
  };
50
44
  };