@kollors/deep-json-server 0.6.0 → 0.7.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 +41 -12
- package/README.ru.md +41 -12
- package/index.js +5 -0
- package/package.json +9 -2
- package/src/cli.js +3 -3
- package/src/config.js +14 -5
- package/src/constants.js +1 -1
- package/src/database.js +19 -28
- package/src/files.js +394 -129
- package/src/openapi/document.js +109 -32
- package/src/query/filter.js +11 -10
- package/src/query/pagination.js +2 -2
- package/src/relation-metadata.js +1 -0
- package/src/server.js +47 -45
- package/src/utils.js +30 -0
- package/types/index.d.ts +10 -0
- package/types/src/config.d.ts +28 -41
- package/types/src/constants.d.ts +1 -1
- package/types/src/database.d.ts +25 -3
- package/types/src/files.d.ts +61 -21
- package/types/src/openapi/document.d.ts +2 -2
- package/types/src/server.d.ts +9 -8
- package/types/src/utils.d.ts +8 -0
package/src/openapi/document.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_MAX_PAGE_SIZE, DEFAULT_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';
|
|
@@ -85,8 +85,11 @@ const addReverseRelations = (schemas, rawSchemas, resources, componentNames) =>
|
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
const createParameters = (maxPageSize) => ({
|
|
88
|
-
|
|
88
|
+
ContentDirectory: { description: 'URI-encoded relative storage directory', in: 'header', name: 'Content-Directory', schema: { type: 'string' } },
|
|
89
|
+
ContentName: { description: 'URI-encoded file name', in: 'header', name: 'Content-Name', required: true, schema: { type: 'string' } },
|
|
90
|
+
ContentOverride: { description: 'Overwrite an existing file at the same path', in: 'header', name: 'Content-Override', schema: { default: false, type: 'boolean' } },
|
|
89
91
|
Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
|
|
92
|
+
FilePath: { allowReserved: true, description: 'URI-encoded file path relative to the storage directory', in: 'path', name: 'path', required: true, schema: { type: 'string' } },
|
|
90
93
|
Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
|
|
91
94
|
Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
|
|
92
95
|
PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum: maxPageSize, minimum: 1, type: 'integer' } },
|
|
@@ -100,52 +103,110 @@ const createParameterReference = (name) => ({ $ref: `#/components/parameters/${n
|
|
|
100
103
|
const createRequestBody = (name) => ({ required: true, ...createJsonContent(createSchemaReference(name)) });
|
|
101
104
|
|
|
102
105
|
const createFilePaths = () => ({
|
|
103
|
-
'/_files': {
|
|
106
|
+
'/_files/download/{path}': {
|
|
107
|
+
get: {
|
|
108
|
+
operationId: 'downloadFile',
|
|
109
|
+
parameters: [createParameterReference('FilePath')],
|
|
110
|
+
responses: {
|
|
111
|
+
200: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, description: 'File download' },
|
|
112
|
+
400: createResponse('Invalid path', createSchemaReference('Error')),
|
|
113
|
+
404: createResponse('Not found', createSchemaReference('Error')),
|
|
114
|
+
},
|
|
115
|
+
tags: ['files'],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
'/_files/metadata/{path}': {
|
|
119
|
+
get: {
|
|
120
|
+
operationId: 'getFileMetadata',
|
|
121
|
+
parameters: [createParameterReference('FilePath')],
|
|
122
|
+
responses: {
|
|
123
|
+
200: createResponse('File metadata', createSchemaReference('FileMetadata')),
|
|
124
|
+
400: createResponse('Invalid path', createSchemaReference('Error')),
|
|
125
|
+
404: createResponse('Not found', createSchemaReference('Error')),
|
|
126
|
+
},
|
|
127
|
+
tags: ['files'],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
'/_files/storage': {
|
|
104
131
|
post: {
|
|
105
132
|
operationId: 'uploadFile',
|
|
106
|
-
parameters: [
|
|
133
|
+
parameters: ['ContentName', 'ContentDirectory', 'ContentOverride'].map(createParameterReference),
|
|
107
134
|
requestBody: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, required: true },
|
|
108
135
|
responses: {
|
|
109
|
-
|
|
136
|
+
200: createResponse('Overwritten', createSchemaReference('FileMetadata')),
|
|
137
|
+
201: createResponse('Created', createSchemaReference('FileMetadata')),
|
|
110
138
|
400: createResponse('Invalid request', createSchemaReference('Error')),
|
|
139
|
+
409: createResponse('Already exists', createSchemaReference('Error')),
|
|
111
140
|
413: createResponse('File is too large', createSchemaReference('Error')),
|
|
112
141
|
415: createResponse('Unsupported media type', createSchemaReference('Error')),
|
|
113
142
|
},
|
|
114
143
|
tags: ['files'],
|
|
115
144
|
},
|
|
116
145
|
},
|
|
117
|
-
'/_files/{
|
|
146
|
+
'/_files/storage/{path}': {
|
|
118
147
|
delete: {
|
|
119
|
-
operationId: '
|
|
120
|
-
parameters: [createParameterReference('
|
|
121
|
-
responses: {
|
|
148
|
+
operationId: 'deleteFile',
|
|
149
|
+
parameters: [createParameterReference('FilePath')],
|
|
150
|
+
responses: {
|
|
151
|
+
204: { description: 'Deleted' },
|
|
152
|
+
400: createResponse('Invalid path', createSchemaReference('Error')),
|
|
153
|
+
404: createResponse('Not found', createSchemaReference('Error')),
|
|
154
|
+
},
|
|
122
155
|
tags: ['files'],
|
|
123
156
|
},
|
|
124
157
|
get: {
|
|
125
|
-
operationId: '
|
|
126
|
-
parameters: [createParameterReference('
|
|
158
|
+
operationId: 'getFileContent',
|
|
159
|
+
parameters: [createParameterReference('FilePath')],
|
|
127
160
|
responses: {
|
|
128
161
|
200: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, description: 'File contents' },
|
|
162
|
+
400: createResponse('Invalid path', createSchemaReference('Error')),
|
|
163
|
+
404: createResponse('Not found', createSchemaReference('Error')),
|
|
164
|
+
},
|
|
165
|
+
tags: ['files'],
|
|
166
|
+
},
|
|
167
|
+
patch: {
|
|
168
|
+
operationId: 'updateFile',
|
|
169
|
+
parameters: [createParameterReference('FilePath')],
|
|
170
|
+
requestBody: createRequestBody('FileUpdate'),
|
|
171
|
+
responses: {
|
|
172
|
+
200: createResponse('Updated', createSchemaReference('FileMetadata')),
|
|
173
|
+
400: createResponse('Invalid request', createSchemaReference('Error')),
|
|
129
174
|
404: createResponse('Not found', createSchemaReference('Error')),
|
|
175
|
+
409: createResponse('Already exists', createSchemaReference('Error')),
|
|
176
|
+
413: createResponse('Request is too large', createSchemaReference('Error')),
|
|
177
|
+
415: createResponse('Unsupported media type', createSchemaReference('Error')),
|
|
130
178
|
},
|
|
131
179
|
tags: ['files'],
|
|
132
180
|
},
|
|
133
181
|
},
|
|
134
182
|
});
|
|
135
183
|
|
|
136
|
-
const
|
|
184
|
+
const createResourceOperationIds = (resource) => {
|
|
137
185
|
const resourceName = toPascalCase(resource);
|
|
138
186
|
|
|
187
|
+
return {
|
|
188
|
+
create: `post${resourceName}`,
|
|
189
|
+
get: `get${resourceName}ById`,
|
|
190
|
+
list: `get${resourceName}`,
|
|
191
|
+
remove: `delete${resourceName}ById`,
|
|
192
|
+
replace: `put${resourceName}ById`,
|
|
193
|
+
update: `patch${resourceName}ById`,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const createResourcePaths = (resource, componentName) => {
|
|
198
|
+
const operationIds = createResourceOperationIds(resource);
|
|
199
|
+
|
|
139
200
|
return {
|
|
140
201
|
[`/${resource}`]: {
|
|
141
202
|
get: {
|
|
142
|
-
operationId:
|
|
203
|
+
operationId: operationIds.list,
|
|
143
204
|
parameters: ['Page', 'PerPage', 'Sort', 'Where', 'Embed'].map(createParameterReference),
|
|
144
205
|
responses: { 200: createResponse('Successful response', createSchemaReference(`${componentName}Page`)), 400: createResponse('Invalid query', createSchemaReference('Error')) },
|
|
145
206
|
tags: [resource],
|
|
146
207
|
},
|
|
147
208
|
post: {
|
|
148
|
-
operationId:
|
|
209
|
+
operationId: operationIds.create,
|
|
149
210
|
requestBody: createRequestBody(`${componentName}Create`),
|
|
150
211
|
responses: { 201: createResponse('Created', createSchemaReference(componentName)), 400: createResponse('Invalid request', createSchemaReference('Error')) },
|
|
151
212
|
tags: [resource],
|
|
@@ -153,13 +214,13 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
153
214
|
},
|
|
154
215
|
[`/${resource}/{id}`]: {
|
|
155
216
|
delete: {
|
|
156
|
-
operationId:
|
|
217
|
+
operationId: operationIds.remove,
|
|
157
218
|
parameters: [createParameterReference('Id')],
|
|
158
219
|
responses: { 200: createResponse('Deleted', createSchemaReference(componentName)), 404: createResponse('Not found', createSchemaReference('Error')) },
|
|
159
220
|
tags: [resource],
|
|
160
221
|
},
|
|
161
222
|
get: {
|
|
162
|
-
operationId:
|
|
223
|
+
operationId: operationIds.get,
|
|
163
224
|
parameters: [createParameterReference('Id'), createParameterReference('Embed')],
|
|
164
225
|
responses: {
|
|
165
226
|
200: createResponse('Successful response', createSchemaReference(componentName)),
|
|
@@ -169,7 +230,7 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
169
230
|
tags: [resource],
|
|
170
231
|
},
|
|
171
232
|
patch: {
|
|
172
|
-
operationId:
|
|
233
|
+
operationId: operationIds.update,
|
|
173
234
|
parameters: [createParameterReference('Id')],
|
|
174
235
|
requestBody: createRequestBody(`${componentName}Update`),
|
|
175
236
|
responses: {
|
|
@@ -180,7 +241,7 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
180
241
|
tags: [resource],
|
|
181
242
|
},
|
|
182
243
|
put: {
|
|
183
|
-
operationId:
|
|
244
|
+
operationId: operationIds.replace,
|
|
184
245
|
parameters: [createParameterReference('Id')],
|
|
185
246
|
requestBody: createRequestBody(`${componentName}Create`),
|
|
186
247
|
responses: {
|
|
@@ -194,14 +255,16 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
194
255
|
};
|
|
195
256
|
};
|
|
196
257
|
|
|
197
|
-
const getOperationIds = (resource) => {
|
|
198
|
-
const resourceName = toPascalCase(resource);
|
|
199
|
-
|
|
200
|
-
return [`get${resourceName}`, `post${resourceName}`, `delete${resourceName}ById`, `get${resourceName}ById`, `patch${resourceName}ById`, `put${resourceName}ById`];
|
|
201
|
-
};
|
|
202
|
-
|
|
203
258
|
const validateGeneratedNames = (resources, componentNames, files) => {
|
|
204
|
-
const schemaOwners = new Map([
|
|
259
|
+
const schemaOwners = new Map([
|
|
260
|
+
['Error', 'встроенная схема ошибки'],
|
|
261
|
+
...(files
|
|
262
|
+
? [
|
|
263
|
+
['FileMetadata', 'встроенная схема метаданных файла'],
|
|
264
|
+
['FileUpdate', 'встроенная схема изменения файла'],
|
|
265
|
+
]
|
|
266
|
+
: []),
|
|
267
|
+
]);
|
|
205
268
|
const operationOwners = new Map();
|
|
206
269
|
|
|
207
270
|
resources.forEach((resource) => {
|
|
@@ -221,7 +284,7 @@ const validateGeneratedNames = (resources, componentNames, files) => {
|
|
|
221
284
|
schemaOwners.set(schemaName, resource);
|
|
222
285
|
});
|
|
223
286
|
|
|
224
|
-
|
|
287
|
+
Object.values(createResourceOperationIds(resource)).forEach((operationId) => {
|
|
225
288
|
const owner = operationOwners.get(operationId);
|
|
226
289
|
|
|
227
290
|
if (owner != null) {
|
|
@@ -234,12 +297,12 @@ const validateGeneratedNames = (resources, componentNames, files) => {
|
|
|
234
297
|
};
|
|
235
298
|
|
|
236
299
|
/**
|
|
237
|
-
* Builds an OpenAPI
|
|
238
|
-
* @param {{ database: Record<string, Array<Record<string, unknown>>>, files?: boolean, maxPageSize?: number, schema?: Record<string, unknown> }} options
|
|
300
|
+
* Builds an OpenAPI document without runtime server addresses.
|
|
301
|
+
* @param {{ database: Record<string, Array<Record<string, unknown>>>, files?: boolean, maxPageSize?: number, schema?: Record<string, unknown> }} options Source data and schema settings.
|
|
239
302
|
* @returns {Record<string, unknown>} OpenAPI document.
|
|
240
303
|
*/
|
|
241
304
|
export function buildOpenapiDocument(options) {
|
|
242
|
-
const { database, files = false, maxPageSize =
|
|
305
|
+
const { database, files = false, maxPageSize = DEFAULT_MAX_PAGE_SIZE, schema: schemaConfig = {} } = options ?? {};
|
|
243
306
|
|
|
244
307
|
if (typeof files !== 'boolean') {
|
|
245
308
|
throw new Error('Ключ files должен содержать boolean');
|
|
@@ -280,9 +343,23 @@ export function buildOpenapiDocument(options) {
|
|
|
280
343
|
const schemas = {
|
|
281
344
|
Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
|
|
282
345
|
...(files && {
|
|
283
|
-
|
|
284
|
-
properties: {
|
|
285
|
-
|
|
346
|
+
FileMetadata: {
|
|
347
|
+
properties: {
|
|
348
|
+
directory: { type: 'string' },
|
|
349
|
+
downloadUrl: { type: 'string' },
|
|
350
|
+
metadataUrl: { type: 'string' },
|
|
351
|
+
mimeType: { type: 'string' },
|
|
352
|
+
name: { type: 'string' },
|
|
353
|
+
size: { minimum: 0, type: 'integer' },
|
|
354
|
+
url: { type: 'string' },
|
|
355
|
+
},
|
|
356
|
+
required: ['directory', 'downloadUrl', 'metadataUrl', 'mimeType', 'name', 'size', 'url'],
|
|
357
|
+
type: 'object',
|
|
358
|
+
},
|
|
359
|
+
FileUpdate: {
|
|
360
|
+
additionalProperties: false,
|
|
361
|
+
anyOf: [{ required: ['directory'] }, { required: ['name'] }],
|
|
362
|
+
properties: { directory: { type: 'string' }, name: { type: 'string' } },
|
|
286
363
|
type: 'object',
|
|
287
364
|
},
|
|
288
365
|
}),
|
package/src/query/filter.js
CHANGED
|
@@ -10,12 +10,21 @@ const isFilterEqual = (left, right) => {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
if (typeof left === 'number' && typeof right === 'string' && NUMBER_PATTERN.test(right)) {
|
|
13
|
+
// Query parameters are strings, so numeric strings must match stored numbers.
|
|
13
14
|
return left === Number(right);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
return typeof right === 'number' && typeof left === 'string' && NUMBER_PATTERN.test(left) && right === Number(left);
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
const validateLogicalConditions = (operator, value) => {
|
|
21
|
+
if (!Array.isArray(value) || (operator === 'or' && value.length === 0) || value.some((condition) => !isObject(condition))) {
|
|
22
|
+
throw createHttpError(400, `Оператор «${operator}» должен содержать ${operator === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
|
|
19
28
|
const matchesOperator = (field, operator, expectedValue) => {
|
|
20
29
|
switch (operator) {
|
|
21
30
|
case 'contains':
|
|
@@ -191,11 +200,7 @@ const validateCondition = (condition, samples, path) => {
|
|
|
191
200
|
|
|
192
201
|
Object.entries(condition).forEach(([key, value]) => {
|
|
193
202
|
if (key === 'and' || key === 'or') {
|
|
194
|
-
|
|
195
|
-
throw createHttpError(400, `Оператор «${key}» должен содержать ${key === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
value.forEach((nestedWhere) => {
|
|
203
|
+
validateLogicalConditions(key, value).forEach((nestedWhere) => {
|
|
199
204
|
validateWhere(nestedWhere, samples, path);
|
|
200
205
|
});
|
|
201
206
|
return;
|
|
@@ -246,11 +251,7 @@ const validateCondition = (condition, samples, path) => {
|
|
|
246
251
|
export const validateWhere = (where, items, path = '') => {
|
|
247
252
|
Object.entries(where).forEach(([key, condition]) => {
|
|
248
253
|
if (key === 'and' || key === 'or') {
|
|
249
|
-
|
|
250
|
-
throw createHttpError(400, `Оператор «${key}» должен содержать ${key === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
condition.forEach((nestedWhere) => {
|
|
254
|
+
validateLogicalConditions(key, condition).forEach((nestedWhere) => {
|
|
254
255
|
validateWhere(nestedWhere, items, path);
|
|
255
256
|
});
|
|
256
257
|
return;
|
package/src/query/pagination.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE } from '../constants.js';
|
|
2
2
|
import { createHttpError } from '../utils.js';
|
|
3
3
|
|
|
4
4
|
const parsePositiveInteger = (value, name, defaultValue) => {
|
|
@@ -19,7 +19,7 @@ const parsePositiveInteger = (value, name, defaultValue) => {
|
|
|
19
19
|
return number;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
export const parsePagination = (query, maxPageSize =
|
|
22
|
+
export const parsePagination = (query, maxPageSize = DEFAULT_MAX_PAGE_SIZE) => {
|
|
23
23
|
const page = parsePositiveInteger(query._page, '_page', 1);
|
|
24
24
|
const pageSize = parsePositiveInteger(query._perPage, '_perPage', DEFAULT_PAGE_SIZE);
|
|
25
25
|
|
package/src/relation-metadata.js
CHANGED
|
@@ -15,6 +15,7 @@ export const resolveRelationResource = (resourceNames, relation, sourceResource)
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export const getRelationMetadata = (key, resourceNames, sourceResource) => {
|
|
18
|
+
// Relation fields follow the <resource>Id and <resource>Ids conventions.
|
|
18
19
|
const match = key.match(/^(.+)(Id|Ids)$/);
|
|
19
20
|
|
|
20
21
|
if (match == null) {
|
package/src/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Fastify from 'fastify';
|
|
2
2
|
import { normalizeServerConfig } from './config.js';
|
|
3
|
-
import { DEFAULT_HOST, DEFAULT_MAX_FILE_SIZE,
|
|
3
|
+
import { DEFAULT_HOST, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_PAGE_SIZE, DEFAULT_PORT } from './constants.js';
|
|
4
4
|
import { createDatabaseStore, createId, findItem, getCollection } from './database.js';
|
|
5
5
|
import { createFileStore, registerFileRoutes } from './files.js';
|
|
6
6
|
import { resolveSchemaConfig } from './openapi/config.js';
|
|
@@ -11,14 +11,15 @@ import { embedItem, parseEmbedPaths, validateEmbedPaths } from './relations.js';
|
|
|
11
11
|
import { createHttpError, getResourceNames, isObject } from './utils.js';
|
|
12
12
|
|
|
13
13
|
/** @typedef {Record<string, unknown>} OpenapiDocument */
|
|
14
|
+
/** @typedef {{ fastify: () => import('fastify').FastifyInstance, openapi: () => Promise<OpenapiDocument> }} ServerFacade */
|
|
14
15
|
|
|
15
16
|
const CORS_HEADERS = {
|
|
16
|
-
'Access-Control-Allow-Headers': 'Content-Name, Content-Type',
|
|
17
|
+
'Access-Control-Allow-Headers': 'Content-Directory, Content-Name, Content-Override, Content-Type',
|
|
17
18
|
'Access-Control-Allow-Methods': 'DELETE, GET, OPTIONS, PATCH, POST, PUT',
|
|
18
19
|
'Access-Control-Allow-Origin': '*',
|
|
19
20
|
};
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
+
const getJsonObjectBody = (body) => {
|
|
22
23
|
if (!isObject(body)) {
|
|
23
24
|
throw createHttpError(400, 'Тело запроса должно быть JSON-объектом');
|
|
24
25
|
}
|
|
@@ -28,7 +29,7 @@ const getRequestBody = (body) => {
|
|
|
28
29
|
|
|
29
30
|
const getSchemaName = (reference) => reference.split('/').at(-1);
|
|
30
31
|
|
|
31
|
-
const addRequestSchemas = (
|
|
32
|
+
const addRequestSchemas = (fastify, document, resources) => {
|
|
32
33
|
const requestSchemaNames = new Set(
|
|
33
34
|
resources.flatMap((resource) => {
|
|
34
35
|
const resourcePath = document.paths[`/${resource}`];
|
|
@@ -39,17 +40,17 @@ const addRequestSchemas = (server, document, resources) => {
|
|
|
39
40
|
);
|
|
40
41
|
|
|
41
42
|
requestSchemaNames.forEach((schemaName) => {
|
|
42
|
-
|
|
43
|
+
fastify.addSchema({ $id: schemaName, ...document.components.schemas[schemaName] });
|
|
43
44
|
});
|
|
44
45
|
};
|
|
45
46
|
|
|
46
|
-
const registerResourceRoutes = (
|
|
47
|
+
const registerResourceRoutes = (fastify, store, resource, document, maxPageSize) => {
|
|
47
48
|
const resourcePath = `/${resource}`;
|
|
48
49
|
const itemPath = `/${resource}/:id`;
|
|
49
50
|
const createSchemaName = getSchemaName(document.paths[resourcePath].post.requestBody.content['application/json'].schema.$ref);
|
|
50
51
|
const updateSchemaName = getSchemaName(document.paths[`/${resource}/{id}`].patch.requestBody.content['application/json'].schema.$ref);
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
fastify.get(resourcePath, async (request) => {
|
|
53
54
|
const collection = getCollection(store.database, resource);
|
|
54
55
|
const where = parseWhere(request.query);
|
|
55
56
|
const embedPaths = parseEmbedPaths(request.query._embed);
|
|
@@ -68,7 +69,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
68
69
|
return paginateItems(sortedItems, pagination.page, pagination.pageSize);
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
fastify.get(itemPath, async (request) => {
|
|
72
73
|
const collection = getCollection(store.database, resource);
|
|
73
74
|
const item = findItem(collection, request.params.id);
|
|
74
75
|
const embedPaths = parseEmbedPaths(request.query._embed);
|
|
@@ -82,10 +83,10 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
82
83
|
return embedItem(store.database, item, resource, embedPaths);
|
|
83
84
|
});
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
fastify.post(resourcePath, { schema: { body: { $ref: `${createSchemaName}#` } } }, async (request, reply) => {
|
|
86
87
|
const item = await store.update((database) => {
|
|
87
88
|
const collection = getCollection(database, resource);
|
|
88
|
-
const createdItem = { ...
|
|
89
|
+
const createdItem = { ...getJsonObjectBody(request.body), id: createId(collection) };
|
|
89
90
|
|
|
90
91
|
collection.push(createdItem);
|
|
91
92
|
|
|
@@ -95,7 +96,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
95
96
|
return reply.code(201).send(item);
|
|
96
97
|
});
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
fastify.put(itemPath, { schema: { body: { $ref: `${createSchemaName}#` } } }, async (request) =>
|
|
99
100
|
store.update((database) => {
|
|
100
101
|
const collection = getCollection(database, resource);
|
|
101
102
|
const currentItem = findItem(collection, request.params.id);
|
|
@@ -104,7 +105,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
104
105
|
throw createHttpError(404, 'Запись не найдена');
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
const item = { ...
|
|
108
|
+
const item = { ...getJsonObjectBody(request.body), id: currentItem.id };
|
|
108
109
|
|
|
109
110
|
collection.splice(collection.indexOf(currentItem), 1, item);
|
|
110
111
|
|
|
@@ -112,7 +113,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
112
113
|
}),
|
|
113
114
|
);
|
|
114
115
|
|
|
115
|
-
|
|
116
|
+
fastify.patch(itemPath, { schema: { body: { $ref: `${updateSchemaName}#` } } }, async (request) =>
|
|
116
117
|
store.update((database) => {
|
|
117
118
|
const collection = getCollection(database, resource);
|
|
118
119
|
const currentItem = findItem(collection, request.params.id);
|
|
@@ -121,7 +122,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
121
122
|
throw createHttpError(404, 'Запись не найдена');
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
const item = { ...currentItem, ...
|
|
125
|
+
const item = { ...currentItem, ...getJsonObjectBody(request.body), id: currentItem.id };
|
|
125
126
|
|
|
126
127
|
collection.splice(collection.indexOf(currentItem), 1, item);
|
|
127
128
|
|
|
@@ -129,7 +130,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
129
130
|
}),
|
|
130
131
|
);
|
|
131
132
|
|
|
132
|
-
|
|
133
|
+
fastify.delete(itemPath, async (request) =>
|
|
133
134
|
store.update((database) => {
|
|
134
135
|
const collection = getCollection(database, resource);
|
|
135
136
|
const currentItem = findItem(collection, request.params.id);
|
|
@@ -146,21 +147,21 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
|
|
|
146
147
|
};
|
|
147
148
|
|
|
148
149
|
/**
|
|
149
|
-
* Creates
|
|
150
|
-
* @param {import('./config.js').DeepJsonServerConfig}
|
|
150
|
+
* Creates lazy Fastify and OpenAPI accessors from one configuration.
|
|
151
|
+
* @param {import('./config.js').DeepJsonServerConfig} config Server configuration.
|
|
151
152
|
* @param {{ files?: boolean }} [features] Optional feature switches.
|
|
152
|
-
* @returns {Promise<
|
|
153
|
+
* @returns {Promise<ServerFacade>} Server facade.
|
|
153
154
|
*/
|
|
154
|
-
export async function createServer(
|
|
155
|
-
const
|
|
156
|
-
const filesEnabled = features.files ??
|
|
157
|
-
const { logger = true, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxPageSize =
|
|
158
|
-
const store = await createDatabaseStore(
|
|
159
|
-
const schema = await resolveSchemaConfig(
|
|
160
|
-
const fileStore = filesEnabled &&
|
|
155
|
+
export async function createServer(config, features = {}) {
|
|
156
|
+
const normalizedConfig = normalizeServerConfig(config);
|
|
157
|
+
const filesEnabled = features.files ?? normalizedConfig.files != null;
|
|
158
|
+
const { logger = true, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxPageSize = DEFAULT_MAX_PAGE_SIZE } = normalizedConfig.server;
|
|
159
|
+
const store = await createDatabaseStore(normalizedConfig.database);
|
|
160
|
+
const schema = await resolveSchemaConfig(normalizedConfig.database.schema);
|
|
161
|
+
const fileStore = filesEnabled && normalizedConfig.files != null ? await createFileStore(normalizedConfig.files) : undefined;
|
|
161
162
|
let fastifyInstance;
|
|
162
163
|
|
|
163
|
-
if (filesEnabled &&
|
|
164
|
+
if (filesEnabled && normalizedConfig.files == null) {
|
|
164
165
|
throw new Error('Для файловых маршрутов укажите секцию config.files');
|
|
165
166
|
}
|
|
166
167
|
|
|
@@ -172,45 +173,46 @@ export async function createServer(options, features = {}) {
|
|
|
172
173
|
schema,
|
|
173
174
|
});
|
|
174
175
|
|
|
175
|
-
const
|
|
176
|
+
const getFastify = () => {
|
|
176
177
|
if (fastifyInstance != null) {
|
|
177
178
|
return fastifyInstance;
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
const document = buildDocument();
|
|
181
182
|
const resources = getResourceNames(store.database.data);
|
|
182
|
-
const
|
|
183
|
-
const
|
|
183
|
+
const fastify = Fastify({ logger });
|
|
184
|
+
const originalListen = fastify.listen.bind(fastify);
|
|
184
185
|
|
|
185
|
-
|
|
186
|
+
// Calling fastify().listen() without arguments uses config defaults.
|
|
187
|
+
fastify.listen = (...args) => originalListen(...(args.length === 0 ? [{ host: normalizedConfig.server.host ?? DEFAULT_HOST, port: normalizedConfig.server.port ?? DEFAULT_PORT }] : args));
|
|
186
188
|
|
|
187
|
-
addRequestSchemas(
|
|
189
|
+
addRequestSchemas(fastify, document, resources);
|
|
188
190
|
|
|
189
|
-
|
|
191
|
+
fastify.addHook('onRequest', async (_request, reply) => {
|
|
190
192
|
Object.entries(CORS_HEADERS).forEach(([header, value]) => {
|
|
191
193
|
reply.header(header, value);
|
|
192
194
|
});
|
|
193
195
|
});
|
|
194
196
|
|
|
195
|
-
|
|
197
|
+
fastify.addHook('preHandler', async (request) => {
|
|
196
198
|
if (request.method === 'GET') {
|
|
197
199
|
await store.read();
|
|
198
200
|
}
|
|
199
201
|
});
|
|
200
202
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
203
|
+
fastify.options('/', async (_request, reply) => reply.code(204).send());
|
|
204
|
+
fastify.options('/*', async (_request, reply) => reply.code(204).send());
|
|
205
|
+
fastify.get('/', async () => ({ resources }));
|
|
204
206
|
|
|
205
207
|
resources.forEach((resource) => {
|
|
206
|
-
registerResourceRoutes(
|
|
208
|
+
registerResourceRoutes(fastify, store, resource, document, maxPageSize);
|
|
207
209
|
});
|
|
208
210
|
|
|
209
211
|
if (fileStore != null) {
|
|
210
|
-
registerFileRoutes(
|
|
212
|
+
registerFileRoutes(fastify, { maxFileSize, store: fileStore });
|
|
211
213
|
}
|
|
212
214
|
|
|
213
|
-
|
|
215
|
+
fastify.setErrorHandler((error, request, reply) => {
|
|
214
216
|
const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
|
|
215
217
|
|
|
216
218
|
if (statusCode === 500) {
|
|
@@ -220,7 +222,7 @@ export async function createServer(options, features = {}) {
|
|
|
220
222
|
return reply.code(statusCode).send({ error: error.message });
|
|
221
223
|
});
|
|
222
224
|
|
|
223
|
-
fastifyInstance =
|
|
225
|
+
fastifyInstance = fastify;
|
|
224
226
|
|
|
225
227
|
return fastifyInstance;
|
|
226
228
|
};
|
|
@@ -231,18 +233,18 @@ export async function createServer(options, features = {}) {
|
|
|
231
233
|
const document = createOpenapi({
|
|
232
234
|
database: store.database.data,
|
|
233
235
|
files: filesEnabled,
|
|
234
|
-
host:
|
|
236
|
+
host: normalizedConfig.server.host,
|
|
235
237
|
maxPageSize,
|
|
236
|
-
port:
|
|
238
|
+
port: normalizedConfig.server.port,
|
|
237
239
|
schema,
|
|
238
240
|
});
|
|
239
241
|
|
|
240
|
-
if (
|
|
241
|
-
await writeOpenapi(document,
|
|
242
|
+
if (normalizedConfig.openapi.path != null) {
|
|
243
|
+
await writeOpenapi(document, normalizedConfig.openapi.path);
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
return document;
|
|
245
247
|
};
|
|
246
248
|
|
|
247
|
-
return { fastify, openapi };
|
|
249
|
+
return { fastify: getFastify, openapi };
|
|
248
250
|
}
|
package/src/utils.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import { resolve } from 'node:path';
|
|
2
3
|
import pluralize from 'pluralize';
|
|
3
4
|
|
|
@@ -11,6 +12,35 @@ export const createHttpError = (statusCode, message) => {
|
|
|
11
12
|
return error;
|
|
12
13
|
};
|
|
13
14
|
|
|
15
|
+
/** @returns {<T>(operation: () => T | Promise<T>) => Promise<T>} Serialized operation scheduler. */
|
|
16
|
+
export const createSerialQueue = () => {
|
|
17
|
+
let queue = Promise.resolve();
|
|
18
|
+
|
|
19
|
+
return (operation) => {
|
|
20
|
+
const pendingOperation = queue.then(operation);
|
|
21
|
+
|
|
22
|
+
// Keep the queue usable after a failed operation.
|
|
23
|
+
queue = pendingOperation.catch(() => undefined);
|
|
24
|
+
|
|
25
|
+
return pendingOperation;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a random ID that is not currently in use.
|
|
31
|
+
* @param {(id: string) => boolean} isUsed Checks whether an ID already exists.
|
|
32
|
+
* @returns {string} Unique ID.
|
|
33
|
+
*/
|
|
34
|
+
export const createUniqueId = (isUsed) => {
|
|
35
|
+
let id;
|
|
36
|
+
|
|
37
|
+
do {
|
|
38
|
+
id = randomBytes(8).toString('base64url');
|
|
39
|
+
} while (isUsed(id));
|
|
40
|
+
|
|
41
|
+
return id;
|
|
42
|
+
};
|
|
43
|
+
|
|
14
44
|
export const getResourceNames = (data) => Object.keys(data);
|
|
15
45
|
export const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
46
|
export const isSafeKey = (key) => !UNSAFE_KEYS.has(key);
|
package/types/index.d.ts
CHANGED
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
/** @typedef {import('./src/config.js').DatabaseConfig} DatabaseConfig */
|
|
3
3
|
/** @typedef {import('./src/config.js').FilesConfig} FilesConfig */
|
|
4
4
|
/** @typedef {import('./src/config.js').MemoryFile} MemoryFile */
|
|
5
|
+
/** @typedef {import('./src/config.js').OpenapiConfig} OpenapiConfig */
|
|
6
|
+
/** @typedef {import('./src/config.js').ServerConfig} ServerConfig */
|
|
7
|
+
/** @typedef {import('./src/files.js').FileMetadata} FileMetadata */
|
|
8
|
+
/** @typedef {import('./src/files.js').FileUpdate} FileUpdate */
|
|
5
9
|
/** @typedef {import('./src/server.js').OpenapiDocument} OpenapiDocument */
|
|
10
|
+
/** @typedef {import('./src/server.js').ServerFacade} ServerFacade */
|
|
6
11
|
export type DeepJsonServerConfig = import('./src/config.js').DeepJsonServerConfig;
|
|
7
12
|
export type DatabaseConfig = import('./src/config.js').DatabaseConfig;
|
|
8
13
|
export type FilesConfig = import('./src/config.js').FilesConfig;
|
|
9
14
|
export type MemoryFile = import('./src/config.js').MemoryFile;
|
|
15
|
+
export type OpenapiConfig = import('./src/config.js').OpenapiConfig;
|
|
16
|
+
export type ServerConfig = import('./src/config.js').ServerConfig;
|
|
17
|
+
export type FileMetadata = import('./src/files.js').FileMetadata;
|
|
18
|
+
export type FileUpdate = import('./src/files.js').FileUpdate;
|
|
10
19
|
export type OpenapiDocument = import('./src/server.js').OpenapiDocument;
|
|
20
|
+
export type ServerFacade = import('./src/server.js').ServerFacade;
|
|
11
21
|
export { createServer } from './src/server.js';
|