@kollors/deep-json-server 0.5.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 +229 -113
- package/README.ru.md +229 -113
- package/index.js +12 -2
- package/package.json +9 -2
- package/src/cli.js +45 -44
- package/src/config.js +141 -35
- package/src/constants.js +1 -1
- package/src/database.js +40 -20
- package/src/files.js +445 -90
- package/src/openapi/config.js +15 -1
- package/src/openapi/document.js +124 -61
- package/src/openapi/index.js +29 -18
- package/src/query/filter.js +11 -10
- package/src/query/pagination.js +3 -9
- package/src/relation-metadata.js +1 -0
- package/src/server.js +104 -92
- package/src/utils.js +30 -0
- package/types/index.d.ts +21 -2
- package/types/src/config.d.ts +73 -0
- package/types/src/constants.d.ts +1 -1
- package/types/src/database.d.ts +28 -3
- package/types/src/files.d.ts +63 -4
- package/types/src/openapi/config.d.ts +1 -1
- package/types/src/openapi/document.d.ts +6 -7
- package/types/src/openapi/index.d.ts +9 -14
- package/types/src/query/pagination.d.ts +1 -6
- package/types/src/server.d.ts +12 -28
- package/types/src/utils.d.ts +8 -0
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_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';
|
|
5
5
|
import { applyConfiguredFields, validateSchemaConfig } from './config.js';
|
|
6
6
|
import { ensureGeneratedIdSchema, inferObjectSchema, mergeSchemaOverrides, omitId } from './inference.js';
|
|
7
7
|
|
|
8
|
-
const getServerUrl = (host, port) => {
|
|
9
|
-
const serverPort = Number(port);
|
|
10
|
-
|
|
11
|
-
if (typeof host !== 'string' || host === '') {
|
|
12
|
-
throw new Error('Адрес сервера не должен быть пустым');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65_535) {
|
|
16
|
-
throw new Error('Порт должен быть целым числом от 1 до 65535');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const serverHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
20
|
-
|
|
21
|
-
return `http://${serverHost}:${serverPort}`;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
8
|
const createSchemaReference = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
25
9
|
|
|
26
10
|
const addForwardRelations = (schema, resources, componentNames, sourceResource) => {
|
|
@@ -100,12 +84,15 @@ const addReverseRelations = (schemas, rawSchemas, resources, componentNames) =>
|
|
|
100
84
|
});
|
|
101
85
|
};
|
|
102
86
|
|
|
103
|
-
const createParameters = () => ({
|
|
104
|
-
|
|
87
|
+
const createParameters = (maxPageSize) => ({
|
|
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' } },
|
|
105
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' } },
|
|
106
93
|
Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
|
|
107
94
|
Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
|
|
108
|
-
PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum:
|
|
95
|
+
PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum: maxPageSize, minimum: 1, type: 'integer' } },
|
|
109
96
|
Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
|
|
110
97
|
Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
|
|
111
98
|
});
|
|
@@ -116,52 +103,110 @@ const createParameterReference = (name) => ({ $ref: `#/components/parameters/${n
|
|
|
116
103
|
const createRequestBody = (name) => ({ required: true, ...createJsonContent(createSchemaReference(name)) });
|
|
117
104
|
|
|
118
105
|
const createFilePaths = () => ({
|
|
119
|
-
'/_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': {
|
|
120
131
|
post: {
|
|
121
132
|
operationId: 'uploadFile',
|
|
122
|
-
parameters: [
|
|
133
|
+
parameters: ['ContentName', 'ContentDirectory', 'ContentOverride'].map(createParameterReference),
|
|
123
134
|
requestBody: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, required: true },
|
|
124
135
|
responses: {
|
|
125
|
-
|
|
136
|
+
200: createResponse('Overwritten', createSchemaReference('FileMetadata')),
|
|
137
|
+
201: createResponse('Created', createSchemaReference('FileMetadata')),
|
|
126
138
|
400: createResponse('Invalid request', createSchemaReference('Error')),
|
|
139
|
+
409: createResponse('Already exists', createSchemaReference('Error')),
|
|
127
140
|
413: createResponse('File is too large', createSchemaReference('Error')),
|
|
128
141
|
415: createResponse('Unsupported media type', createSchemaReference('Error')),
|
|
129
142
|
},
|
|
130
143
|
tags: ['files'],
|
|
131
144
|
},
|
|
132
145
|
},
|
|
133
|
-
'/_files/{
|
|
146
|
+
'/_files/storage/{path}': {
|
|
134
147
|
delete: {
|
|
135
|
-
operationId: '
|
|
136
|
-
parameters: [createParameterReference('
|
|
137
|
-
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
|
+
},
|
|
138
155
|
tags: ['files'],
|
|
139
156
|
},
|
|
140
157
|
get: {
|
|
141
|
-
operationId: '
|
|
142
|
-
parameters: [createParameterReference('
|
|
158
|
+
operationId: 'getFileContent',
|
|
159
|
+
parameters: [createParameterReference('FilePath')],
|
|
143
160
|
responses: {
|
|
144
161
|
200: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, description: 'File contents' },
|
|
162
|
+
400: createResponse('Invalid path', createSchemaReference('Error')),
|
|
145
163
|
404: createResponse('Not found', createSchemaReference('Error')),
|
|
146
164
|
},
|
|
147
165
|
tags: ['files'],
|
|
148
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')),
|
|
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')),
|
|
178
|
+
},
|
|
179
|
+
tags: ['files'],
|
|
180
|
+
},
|
|
149
181
|
},
|
|
150
182
|
});
|
|
151
183
|
|
|
152
|
-
const
|
|
184
|
+
const createResourceOperationIds = (resource) => {
|
|
153
185
|
const resourceName = toPascalCase(resource);
|
|
154
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
|
+
|
|
155
200
|
return {
|
|
156
201
|
[`/${resource}`]: {
|
|
157
202
|
get: {
|
|
158
|
-
operationId:
|
|
203
|
+
operationId: operationIds.list,
|
|
159
204
|
parameters: ['Page', 'PerPage', 'Sort', 'Where', 'Embed'].map(createParameterReference),
|
|
160
205
|
responses: { 200: createResponse('Successful response', createSchemaReference(`${componentName}Page`)), 400: createResponse('Invalid query', createSchemaReference('Error')) },
|
|
161
206
|
tags: [resource],
|
|
162
207
|
},
|
|
163
208
|
post: {
|
|
164
|
-
operationId:
|
|
209
|
+
operationId: operationIds.create,
|
|
165
210
|
requestBody: createRequestBody(`${componentName}Create`),
|
|
166
211
|
responses: { 201: createResponse('Created', createSchemaReference(componentName)), 400: createResponse('Invalid request', createSchemaReference('Error')) },
|
|
167
212
|
tags: [resource],
|
|
@@ -169,13 +214,13 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
169
214
|
},
|
|
170
215
|
[`/${resource}/{id}`]: {
|
|
171
216
|
delete: {
|
|
172
|
-
operationId:
|
|
217
|
+
operationId: operationIds.remove,
|
|
173
218
|
parameters: [createParameterReference('Id')],
|
|
174
219
|
responses: { 200: createResponse('Deleted', createSchemaReference(componentName)), 404: createResponse('Not found', createSchemaReference('Error')) },
|
|
175
220
|
tags: [resource],
|
|
176
221
|
},
|
|
177
222
|
get: {
|
|
178
|
-
operationId:
|
|
223
|
+
operationId: operationIds.get,
|
|
179
224
|
parameters: [createParameterReference('Id'), createParameterReference('Embed')],
|
|
180
225
|
responses: {
|
|
181
226
|
200: createResponse('Successful response', createSchemaReference(componentName)),
|
|
@@ -185,7 +230,7 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
185
230
|
tags: [resource],
|
|
186
231
|
},
|
|
187
232
|
patch: {
|
|
188
|
-
operationId:
|
|
233
|
+
operationId: operationIds.update,
|
|
189
234
|
parameters: [createParameterReference('Id')],
|
|
190
235
|
requestBody: createRequestBody(`${componentName}Update`),
|
|
191
236
|
responses: {
|
|
@@ -196,7 +241,7 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
196
241
|
tags: [resource],
|
|
197
242
|
},
|
|
198
243
|
put: {
|
|
199
|
-
operationId:
|
|
244
|
+
operationId: operationIds.replace,
|
|
200
245
|
parameters: [createParameterReference('Id')],
|
|
201
246
|
requestBody: createRequestBody(`${componentName}Create`),
|
|
202
247
|
responses: {
|
|
@@ -210,14 +255,16 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
210
255
|
};
|
|
211
256
|
};
|
|
212
257
|
|
|
213
|
-
const getOperationIds = (resource) => {
|
|
214
|
-
const resourceName = toPascalCase(resource);
|
|
215
|
-
|
|
216
|
-
return [`get${resourceName}`, `post${resourceName}`, `delete${resourceName}ById`, `get${resourceName}ById`, `patch${resourceName}ById`, `put${resourceName}ById`];
|
|
217
|
-
};
|
|
218
|
-
|
|
219
258
|
const validateGeneratedNames = (resources, componentNames, files) => {
|
|
220
|
-
const schemaOwners = new Map([
|
|
259
|
+
const schemaOwners = new Map([
|
|
260
|
+
['Error', 'встроенная схема ошибки'],
|
|
261
|
+
...(files
|
|
262
|
+
? [
|
|
263
|
+
['FileMetadata', 'встроенная схема метаданных файла'],
|
|
264
|
+
['FileUpdate', 'встроенная схема изменения файла'],
|
|
265
|
+
]
|
|
266
|
+
: []),
|
|
267
|
+
]);
|
|
221
268
|
const operationOwners = new Map();
|
|
222
269
|
|
|
223
270
|
resources.forEach((resource) => {
|
|
@@ -237,7 +284,7 @@ const validateGeneratedNames = (resources, componentNames, files) => {
|
|
|
237
284
|
schemaOwners.set(schemaName, resource);
|
|
238
285
|
});
|
|
239
286
|
|
|
240
|
-
|
|
287
|
+
Object.values(createResourceOperationIds(resource)).forEach((operationId) => {
|
|
241
288
|
const owner = operationOwners.get(operationId);
|
|
242
289
|
|
|
243
290
|
if (owner != null) {
|
|
@@ -250,15 +297,23 @@ const validateGeneratedNames = (resources, componentNames, files) => {
|
|
|
250
297
|
};
|
|
251
298
|
|
|
252
299
|
/**
|
|
253
|
-
*
|
|
254
|
-
* @param {Record<string, Array<Record<string, unknown
|
|
255
|
-
* @param {Record<string, unknown>} [schemaConfig] Schema configuration.
|
|
256
|
-
* @param {{ files?: boolean, host?: string, port?: number }} [serverOptions] Server address and optional features used in the generated document.
|
|
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.
|
|
257
302
|
* @returns {Record<string, unknown>} OpenAPI document.
|
|
258
303
|
*/
|
|
259
|
-
export function
|
|
304
|
+
export function buildOpenapiDocument(options) {
|
|
305
|
+
const { database, files = false, maxPageSize = DEFAULT_MAX_PAGE_SIZE, schema: schemaConfig = {} } = options ?? {};
|
|
306
|
+
|
|
307
|
+
if (typeof files !== 'boolean') {
|
|
308
|
+
throw new Error('Ключ files должен содержать boolean');
|
|
309
|
+
}
|
|
310
|
+
|
|
260
311
|
validateDatabase(database);
|
|
261
312
|
|
|
313
|
+
if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
|
|
314
|
+
throw new Error('Максимальный размер страницы должен быть положительным целым числом');
|
|
315
|
+
}
|
|
316
|
+
|
|
262
317
|
if (!isObject(schemaConfig)) {
|
|
263
318
|
throw new Error('Схема базы данных должна содержать JSON-объект');
|
|
264
319
|
}
|
|
@@ -288,9 +343,23 @@ export function createOpenApiDocument(database, schemaConfig = {}, { files = fal
|
|
|
288
343
|
const schemas = {
|
|
289
344
|
Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
|
|
290
345
|
...(files && {
|
|
291
|
-
|
|
292
|
-
properties: {
|
|
293
|
-
|
|
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' } },
|
|
294
363
|
type: 'object',
|
|
295
364
|
},
|
|
296
365
|
}),
|
|
@@ -306,14 +375,9 @@ export function createOpenApiDocument(database, schemaConfig = {}, { files = fal
|
|
|
306
375
|
schemas[`${componentName}Page`] = {
|
|
307
376
|
properties: {
|
|
308
377
|
data: { items: createSchemaReference(componentName), type: 'array' },
|
|
309
|
-
|
|
310
|
-
items: { type: 'integer' },
|
|
311
|
-
last: { type: 'integer' },
|
|
312
|
-
next: { nullable: true, type: 'integer' },
|
|
313
|
-
pages: { type: 'integer' },
|
|
314
|
-
prev: { nullable: true, type: 'integer' },
|
|
378
|
+
total: { minimum: 0, type: 'integer' },
|
|
315
379
|
},
|
|
316
|
-
required: ['data', '
|
|
380
|
+
required: ['data', 'total'],
|
|
317
381
|
type: 'object',
|
|
318
382
|
};
|
|
319
383
|
});
|
|
@@ -321,11 +385,10 @@ export function createOpenApiDocument(database, schemaConfig = {}, { files = fal
|
|
|
321
385
|
addReverseRelations(schemas, rawSchemas, resources, componentNames);
|
|
322
386
|
|
|
323
387
|
return {
|
|
324
|
-
components: { parameters: createParameters(), schemas },
|
|
388
|
+
components: { parameters: createParameters(maxPageSize), schemas },
|
|
325
389
|
info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
|
|
326
390
|
openapi: '3.0.3',
|
|
327
391
|
paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource])), files ? createFilePaths() : {}),
|
|
328
|
-
servers: [{ url: getServerUrl(host, port) }],
|
|
329
392
|
tags: [...resources.map((resource) => ({ name: resource })), ...(files ? [{ name: 'files' }] : [])],
|
|
330
393
|
};
|
|
331
394
|
}
|
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/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
|
|
|
@@ -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
|
};
|
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) {
|