@kollors/deep-json-server 0.4.0 → 0.5.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 +201 -38
- package/README.ru.md +201 -38
- package/package.json +1 -1
- package/src/cli.js +63 -51
- package/src/config.js +97 -0
- package/src/constants.js +1 -0
- package/src/files.js +278 -0
- package/src/openapi/document.js +52 -8
- package/src/openapi/index.js +3 -3
- package/src/server.js +30 -7
- package/types/src/constants.d.ts +1 -0
- package/types/src/files.d.ts +5 -0
- package/types/src/openapi/document.d.ts +3 -2
- package/types/src/openapi/index.d.ts +4 -3
- package/types/src/server.d.ts +8 -2
package/src/files.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
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 { 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 createFileStore = async ({ directoryPath: sourceDirectoryPath, metadataPath: 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, 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 getDownloadName = (name) => encodeURIComponent(basename(name)).replaceAll("'", '%27');
|
|
241
|
+
|
|
242
|
+
export const registerFileRoutes = async (server, { directoryPath, maxFileSize, metadataPath }) => {
|
|
243
|
+
const store = await createFileStore({ directoryPath, metadataPath });
|
|
244
|
+
|
|
245
|
+
server.register((fileServer, _options, done) => {
|
|
246
|
+
fileServer.removeAllContentTypeParsers();
|
|
247
|
+
fileServer.addContentTypeParser('*', (_request, payload, parserDone) => parserDone(null, payload));
|
|
248
|
+
|
|
249
|
+
fileServer.post('/_files', async (request, reply) => {
|
|
250
|
+
const contentLength = Number(request.headers['content-length']);
|
|
251
|
+
|
|
252
|
+
if (Number.isFinite(contentLength) && contentLength > maxFileSize) {
|
|
253
|
+
throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const file = await store.upload({
|
|
257
|
+
maxFileSize,
|
|
258
|
+
mimeType: getMimeType(request.headers['content-type']),
|
|
259
|
+
name: getContentName(request.headers['content-name']),
|
|
260
|
+
stream: request.body,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return reply.code(201).send(file);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
fileServer.get('/_files/:id', async (request, reply) => {
|
|
267
|
+
const { file, path } = await store.get(request.params.id);
|
|
268
|
+
|
|
269
|
+
reply.header('Content-Disposition', `inline; filename*=UTF-8''${getDownloadName(file.name)}`);
|
|
270
|
+
reply.type(file.mimeType);
|
|
271
|
+
|
|
272
|
+
return reply.send(createReadStream(path));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
fileServer.delete('/_files/:id', async (request) => store.remove(request.params.id));
|
|
276
|
+
done();
|
|
277
|
+
});
|
|
278
|
+
};
|
package/src/openapi/document.js
CHANGED
|
@@ -101,6 +101,7 @@ const addReverseRelations = (schemas, rawSchemas, resources, componentNames) =>
|
|
|
101
101
|
};
|
|
102
102
|
|
|
103
103
|
const createParameters = () => ({
|
|
104
|
+
ContentName: { description: 'URI-encoded relative file name', in: 'header', name: 'Content-Name', required: true, schema: { type: 'string' } },
|
|
104
105
|
Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
|
|
105
106
|
Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
|
|
106
107
|
Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
|
|
@@ -114,6 +115,40 @@ const createResponse = (description, schema) => ({ description, ...(schema != nu
|
|
|
114
115
|
const createParameterReference = (name) => ({ $ref: `#/components/parameters/${name}` });
|
|
115
116
|
const createRequestBody = (name) => ({ required: true, ...createJsonContent(createSchemaReference(name)) });
|
|
116
117
|
|
|
118
|
+
const createFilePaths = () => ({
|
|
119
|
+
'/_files': {
|
|
120
|
+
post: {
|
|
121
|
+
operationId: 'uploadFile',
|
|
122
|
+
parameters: [createParameterReference('ContentName')],
|
|
123
|
+
requestBody: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, required: true },
|
|
124
|
+
responses: {
|
|
125
|
+
201: createResponse('Uploaded', createSchemaReference('UploadedFile')),
|
|
126
|
+
400: createResponse('Invalid request', createSchemaReference('Error')),
|
|
127
|
+
413: createResponse('File is too large', createSchemaReference('Error')),
|
|
128
|
+
415: createResponse('Unsupported media type', createSchemaReference('Error')),
|
|
129
|
+
},
|
|
130
|
+
tags: ['files'],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
'/_files/{id}': {
|
|
134
|
+
delete: {
|
|
135
|
+
operationId: 'deleteFileById',
|
|
136
|
+
parameters: [createParameterReference('Id')],
|
|
137
|
+
responses: { 200: createResponse('Deleted', createSchemaReference('UploadedFile')), 404: createResponse('Not found', createSchemaReference('Error')) },
|
|
138
|
+
tags: ['files'],
|
|
139
|
+
},
|
|
140
|
+
get: {
|
|
141
|
+
operationId: 'getFileById',
|
|
142
|
+
parameters: [createParameterReference('Id')],
|
|
143
|
+
responses: {
|
|
144
|
+
200: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, description: 'File contents' },
|
|
145
|
+
404: createResponse('Not found', createSchemaReference('Error')),
|
|
146
|
+
},
|
|
147
|
+
tags: ['files'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
117
152
|
const createResourcePaths = (resource, componentName) => {
|
|
118
153
|
const resourceName = toPascalCase(resource);
|
|
119
154
|
|
|
@@ -181,8 +216,8 @@ const getOperationIds = (resource) => {
|
|
|
181
216
|
return [`get${resourceName}`, `post${resourceName}`, `delete${resourceName}ById`, `get${resourceName}ById`, `patch${resourceName}ById`, `put${resourceName}ById`];
|
|
182
217
|
};
|
|
183
218
|
|
|
184
|
-
const validateGeneratedNames = (resources, componentNames) => {
|
|
185
|
-
const schemaOwners = new Map([['Error', 'встроенная схема ошибки']]);
|
|
219
|
+
const validateGeneratedNames = (resources, componentNames, files) => {
|
|
220
|
+
const schemaOwners = new Map([['Error', 'встроенная схема ошибки'], ...(files ? [['UploadedFile', 'встроенная схема файла']] : [])]);
|
|
186
221
|
const operationOwners = new Map();
|
|
187
222
|
|
|
188
223
|
resources.forEach((resource) => {
|
|
@@ -218,10 +253,10 @@ const validateGeneratedNames = (resources, componentNames) => {
|
|
|
218
253
|
* Creates an OpenAPI 3.0 document from a database and optional schema configuration.
|
|
219
254
|
* @param {Record<string, Array<Record<string, unknown>>>} database Database contents.
|
|
220
255
|
* @param {Record<string, unknown>} [schemaConfig] Schema configuration.
|
|
221
|
-
* @param {{ host?: string, port?: number }} [serverOptions] Server address used in the generated document.
|
|
256
|
+
* @param {{ files?: boolean, host?: string, port?: number }} [serverOptions] Server address and optional features used in the generated document.
|
|
222
257
|
* @returns {Record<string, unknown>} OpenAPI document.
|
|
223
258
|
*/
|
|
224
|
-
export function createOpenApiDocument(database, schemaConfig = {}, { host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
|
|
259
|
+
export function createOpenApiDocument(database, schemaConfig = {}, { files = false, host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
|
|
225
260
|
validateDatabase(database);
|
|
226
261
|
|
|
227
262
|
if (!isObject(schemaConfig)) {
|
|
@@ -239,7 +274,7 @@ export function createOpenApiDocument(database, schemaConfig = {}, { host = DEFA
|
|
|
239
274
|
}),
|
|
240
275
|
);
|
|
241
276
|
|
|
242
|
-
validateGeneratedNames(resources, componentNames);
|
|
277
|
+
validateGeneratedNames(resources, componentNames, files);
|
|
243
278
|
|
|
244
279
|
const rawSchemas = Object.fromEntries(
|
|
245
280
|
resources.map((resource) => {
|
|
@@ -250,7 +285,16 @@ export function createOpenApiDocument(database, schemaConfig = {}, { host = DEFA
|
|
|
250
285
|
return [resource, applyConfiguredFields(ensureGeneratedIdSchema(configuredSchema), resource, resourceConfig)];
|
|
251
286
|
}),
|
|
252
287
|
);
|
|
253
|
-
const schemas = {
|
|
288
|
+
const schemas = {
|
|
289
|
+
Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
|
|
290
|
+
...(files && {
|
|
291
|
+
UploadedFile: {
|
|
292
|
+
properties: { id: { type: 'string' }, mimeType: { type: 'string' }, name: { type: 'string' }, size: { minimum: 0, type: 'integer' }, url: { type: 'string' } },
|
|
293
|
+
required: ['id', 'mimeType', 'name', 'size', 'url'],
|
|
294
|
+
type: 'object',
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
};
|
|
254
298
|
|
|
255
299
|
resources.forEach((resource) => {
|
|
256
300
|
const componentName = componentNames[resource];
|
|
@@ -280,8 +324,8 @@ export function createOpenApiDocument(database, schemaConfig = {}, { host = DEFA
|
|
|
280
324
|
components: { parameters: createParameters(), schemas },
|
|
281
325
|
info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
|
|
282
326
|
openapi: '3.0.3',
|
|
283
|
-
paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource]))),
|
|
327
|
+
paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource])), files ? createFilePaths() : {}),
|
|
284
328
|
servers: [{ url: getServerUrl(host, port) }],
|
|
285
|
-
tags: resources.map((resource) => ({ name: resource })),
|
|
329
|
+
tags: [...resources.map((resource) => ({ name: resource })), ...(files ? [{ name: 'files' }] : [])],
|
|
286
330
|
};
|
|
287
331
|
}
|
package/src/openapi/index.js
CHANGED
|
@@ -9,13 +9,13 @@ export { createOpenApiDocument } from './document.js';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Generates an OpenAPI YAML file.
|
|
12
|
-
* @param {{ databasePath: string, host?: string, outputPath: string, port?: number, schemaPath
|
|
12
|
+
* @param {{ databasePath: string, files?: boolean, host?: string, outputPath: string, port?: number, schemaPath?: string }} options Generation options.
|
|
13
13
|
* @returns {Promise<Record<string, unknown>>} Generated OpenAPI document.
|
|
14
14
|
*/
|
|
15
|
-
export async function generateOpenApi({ databasePath, host, outputPath, port, schemaPath }) {
|
|
15
|
+
export async function generateOpenApi({ databasePath, files = false, host, outputPath, port, schemaPath }) {
|
|
16
16
|
const database = await readDatabaseFile(databasePath);
|
|
17
17
|
const schemaConfig = await readSchemaConfig(schemaPath);
|
|
18
|
-
const document = createOpenApiDocument(database, schemaConfig, { host, port });
|
|
18
|
+
const document = createOpenApiDocument(database, schemaConfig, { files, host, port });
|
|
19
19
|
const resolvedOutputPath = resolve(outputPath);
|
|
20
20
|
|
|
21
21
|
await mkdir(dirname(resolvedOutputPath), { recursive: true });
|
package/src/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Fastify from 'fastify';
|
|
2
|
-
import { DEFAULT_HOST, DEFAULT_PORT, MAX_PAGE_SIZE } from './constants.js';
|
|
2
|
+
import { DEFAULT_HOST, DEFAULT_MAX_FILE_SIZE, DEFAULT_PORT, MAX_PAGE_SIZE } from './constants.js';
|
|
3
3
|
import { createDatabaseStore, createId, findItem, getCollection } from './database.js';
|
|
4
|
+
import { registerFileRoutes } from './files.js';
|
|
4
5
|
import { readSchemaConfig } from './openapi/config.js';
|
|
5
6
|
import { createOpenApiDocument } from './openapi/index.js';
|
|
6
7
|
import { matchesWhere, paginateItems, parsePagination, parseWhere, sortItems, validateWhere } from './query/index.js';
|
|
@@ -8,7 +9,7 @@ import { embedItem, parseEmbedPaths, validateEmbedPaths } from './relations.js';
|
|
|
8
9
|
import { createHttpError, getResourceNames, isObject, resolveDatabasePath } from './utils.js';
|
|
9
10
|
|
|
10
11
|
const CORS_HEADERS = {
|
|
11
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
12
|
+
'Access-Control-Allow-Headers': 'Content-Name, Content-Type',
|
|
12
13
|
'Access-Control-Allow-Methods': 'DELETE, GET, OPTIONS, PATCH, POST, PUT',
|
|
13
14
|
'Access-Control-Allow-Origin': '*',
|
|
14
15
|
};
|
|
@@ -142,16 +143,20 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
142
143
|
|
|
143
144
|
/**
|
|
144
145
|
* Creates a Fastify server without opening a network port.
|
|
145
|
-
* @param {{ databasePath: string, logger?: boolean | Record<string, unknown>, maxPageSize?: number, schemaPath?: string }} options Server options.
|
|
146
|
+
* @param {{ databasePath: string, filesDirectoryPath?: string, filesMetadataPath?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, schemaPath?: string }} options Server options.
|
|
146
147
|
* @returns {Promise<import('fastify').FastifyInstance>} Fastify server.
|
|
147
148
|
*/
|
|
148
149
|
export async function createServer(options) {
|
|
149
|
-
const { databasePath, logger = true, maxPageSize = MAX_PAGE_SIZE, schemaPath } = options ?? {};
|
|
150
|
+
const { databasePath, filesDirectoryPath, filesMetadataPath, logger = true, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxPageSize = MAX_PAGE_SIZE, schemaPath } = options ?? {};
|
|
150
151
|
|
|
151
152
|
if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
|
|
152
153
|
throw new Error('Максимальный размер страницы должен быть положительным целым числом');
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
if (!Number.isInteger(maxFileSize) || maxFileSize < 1) {
|
|
157
|
+
throw new Error('Максимальный размер файла должен быть положительным целым числом');
|
|
158
|
+
}
|
|
159
|
+
|
|
155
160
|
const store = await createDatabaseStore(databasePath);
|
|
156
161
|
const schemaConfig = await readSchemaConfig(schemaPath);
|
|
157
162
|
const document = createOpenApiDocument(store.database.data, schemaConfig);
|
|
@@ -180,6 +185,14 @@ export async function createServer(options) {
|
|
|
180
185
|
registerResourceRoutes(server, store, resource, document, maxPageSize);
|
|
181
186
|
});
|
|
182
187
|
|
|
188
|
+
if ((filesDirectoryPath == null) !== (filesMetadataPath == null)) {
|
|
189
|
+
throw new Error('Для файловых маршрутов укажите filesDirectoryPath и filesMetadataPath');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (filesDirectoryPath != null) {
|
|
193
|
+
await registerFileRoutes(server, { directoryPath: filesDirectoryPath, maxFileSize, metadataPath: filesMetadataPath });
|
|
194
|
+
}
|
|
195
|
+
|
|
183
196
|
server.setErrorHandler((error, request, reply) => {
|
|
184
197
|
const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
|
|
185
198
|
|
|
@@ -195,18 +208,28 @@ export async function createServer(options) {
|
|
|
195
208
|
|
|
196
209
|
/**
|
|
197
210
|
* Creates and starts a Fastify server.
|
|
198
|
-
* @param {{ databasePath: string, host?: string, logger?: boolean | Record<string, unknown>, maxPageSize?: number, port?: number, schemaPath?: string }} options Server options.
|
|
211
|
+
* @param {{ databasePath: string, filesDirectoryPath?: string, filesMetadataPath?: string, host?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, port?: number, schemaPath?: string }} options Server options.
|
|
199
212
|
* @returns {Promise<import('fastify').FastifyInstance>} Listening Fastify server.
|
|
200
213
|
*/
|
|
201
214
|
export async function startServer(options) {
|
|
202
|
-
const {
|
|
215
|
+
const {
|
|
216
|
+
databasePath,
|
|
217
|
+
filesDirectoryPath,
|
|
218
|
+
filesMetadataPath,
|
|
219
|
+
host = DEFAULT_HOST,
|
|
220
|
+
logger = true,
|
|
221
|
+
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
|
222
|
+
maxPageSize = MAX_PAGE_SIZE,
|
|
223
|
+
port = DEFAULT_PORT,
|
|
224
|
+
schemaPath,
|
|
225
|
+
} = options ?? {};
|
|
203
226
|
|
|
204
227
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
205
228
|
throw new Error('Порт должен быть целым числом от 0 до 65535');
|
|
206
229
|
}
|
|
207
230
|
|
|
208
231
|
const resolvedDatabasePath = resolveDatabasePath(databasePath);
|
|
209
|
-
const server = await createServer({ databasePath: resolvedDatabasePath, logger, maxPageSize, schemaPath });
|
|
232
|
+
const server = await createServer({ databasePath: resolvedDatabasePath, filesDirectoryPath, filesMetadataPath, logger, maxFileSize, maxPageSize, schemaPath });
|
|
210
233
|
|
|
211
234
|
await server.listen({ host, port });
|
|
212
235
|
server.log.info({ database: resolvedDatabasePath }, 'Deep JSON Server запущен');
|
package/types/src/constants.d.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Creates an OpenAPI 3.0 document from a database and optional schema configuration.
|
|
3
3
|
* @param {Record<string, Array<Record<string, unknown>>>} database Database contents.
|
|
4
4
|
* @param {Record<string, unknown>} [schemaConfig] Schema configuration.
|
|
5
|
-
* @param {{ host?: string, port?: number }} [serverOptions] Server address used in the generated document.
|
|
5
|
+
* @param {{ files?: boolean, host?: string, port?: number }} [serverOptions] Server address and optional features used in the generated document.
|
|
6
6
|
* @returns {Record<string, unknown>} OpenAPI document.
|
|
7
7
|
*/
|
|
8
|
-
export declare function createOpenApiDocument(database: Record<string, Array<Record<string, unknown>>>, schemaConfig?: Record<string, unknown>, { host, port }?: {
|
|
8
|
+
export declare function createOpenApiDocument(database: Record<string, Array<Record<string, unknown>>>, schemaConfig?: Record<string, unknown>, { files, host, port }?: {
|
|
9
|
+
files?: boolean;
|
|
9
10
|
host?: string;
|
|
10
11
|
port?: number;
|
|
11
12
|
}): Record<string, unknown>;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export { createOpenApiDocument } from './document.js';
|
|
2
2
|
/**
|
|
3
3
|
* Generates an OpenAPI YAML file.
|
|
4
|
-
* @param {{ databasePath: string, host?: string, outputPath: string, port?: number, schemaPath
|
|
4
|
+
* @param {{ databasePath: string, files?: boolean, host?: string, outputPath: string, port?: number, schemaPath?: string }} options Generation options.
|
|
5
5
|
* @returns {Promise<Record<string, unknown>>} Generated OpenAPI document.
|
|
6
6
|
*/
|
|
7
|
-
export declare function generateOpenApi({ databasePath, host, outputPath, port, schemaPath }: {
|
|
7
|
+
export declare function generateOpenApi({ databasePath, files, host, outputPath, port, schemaPath }: {
|
|
8
8
|
databasePath: string;
|
|
9
|
+
files?: boolean;
|
|
9
10
|
host?: string;
|
|
10
11
|
outputPath: string;
|
|
11
12
|
port?: number;
|
|
12
|
-
schemaPath
|
|
13
|
+
schemaPath?: string;
|
|
13
14
|
}): Promise<Record<string, unknown>>;
|
package/types/src/server.d.ts
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creates a Fastify server without opening a network port.
|
|
3
|
-
* @param {{ databasePath: string, logger?: boolean | Record<string, unknown>, maxPageSize?: number, schemaPath?: string }} options Server options.
|
|
3
|
+
* @param {{ databasePath: string, filesDirectoryPath?: string, filesMetadataPath?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, schemaPath?: string }} options Server options.
|
|
4
4
|
* @returns {Promise<import('fastify').FastifyInstance>} Fastify server.
|
|
5
5
|
*/
|
|
6
6
|
export declare function createServer(options: {
|
|
7
7
|
databasePath: string;
|
|
8
|
+
filesDirectoryPath?: string;
|
|
9
|
+
filesMetadataPath?: string;
|
|
8
10
|
logger?: boolean | Record<string, unknown>;
|
|
11
|
+
maxFileSize?: number;
|
|
9
12
|
maxPageSize?: number;
|
|
10
13
|
schemaPath?: string;
|
|
11
14
|
}): Promise<import('fastify').FastifyInstance>;
|
|
12
15
|
/**
|
|
13
16
|
* Creates and starts a Fastify server.
|
|
14
|
-
* @param {{ databasePath: string, host?: string, logger?: boolean | Record<string, unknown>, maxPageSize?: number, port?: number, schemaPath?: string }} options Server options.
|
|
17
|
+
* @param {{ databasePath: string, filesDirectoryPath?: string, filesMetadataPath?: string, host?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, port?: number, schemaPath?: string }} options Server options.
|
|
15
18
|
* @returns {Promise<import('fastify').FastifyInstance>} Listening Fastify server.
|
|
16
19
|
*/
|
|
17
20
|
export declare function startServer(options: {
|
|
18
21
|
databasePath: string;
|
|
22
|
+
filesDirectoryPath?: string;
|
|
23
|
+
filesMetadataPath?: string;
|
|
19
24
|
host?: string;
|
|
20
25
|
logger?: boolean | Record<string, unknown>;
|
|
26
|
+
maxFileSize?: number;
|
|
21
27
|
maxPageSize?: number;
|
|
22
28
|
port?: number;
|
|
23
29
|
schemaPath?: string;
|