@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/README.md +317 -67
- package/README.ru.md +316 -66
- package/index.js +7 -2
- package/package.json +1 -1
- package/src/cli.js +61 -48
- package/src/config.js +194 -0
- package/src/constants.js +1 -0
- package/src/database.js +31 -2
- package/src/files.js +368 -0
- package/src/openapi/config.js +15 -1
- package/src/openapi/document.js +69 -39
- package/src/openapi/index.js +29 -18
- package/src/query/pagination.js +1 -7
- package/src/server.js +91 -58
- package/types/index.d.ts +11 -2
- package/types/src/config.d.ts +86 -0
- package/types/src/constants.d.ts +1 -0
- package/types/src/database.d.ts +5 -2
- package/types/src/files.d.ts +24 -0
- package/types/src/openapi/config.d.ts +1 -1
- package/types/src/openapi/document.d.ts +7 -7
- package/types/src/openapi/index.d.ts +9 -13
- package/types/src/query/pagination.d.ts +1 -6
- package/types/src/server.d.ts +11 -22
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
|
+
};
|
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,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:
|
|
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
|
-
*
|
|
219
|
-
* @param {Record<string, Array<Record<string, unknown
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
}
|
package/src/openapi/index.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
+
};
|
package/src/query/pagination.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|