@kollors/deep-json-server 0.3.2 → 0.4.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 +14 -8
- package/README.ru.md +14 -8
- package/bin/deep-json-server.js +11 -0
- package/index.js +1 -11
- package/package.json +22 -5
- package/src/cli.js +19 -15
- package/src/constants.js +4 -0
- package/src/database.js +128 -0
- package/src/openapi/config.js +110 -0
- package/src/openapi/document.js +287 -0
- package/src/openapi/index.js +25 -0
- package/src/openapi/inference.js +187 -0
- package/src/{query.js → query/filter.js} +23 -113
- package/src/query/index.js +3 -0
- package/src/query/pagination.js +50 -0
- package/src/query/sort.js +63 -0
- package/src/relation-metadata.js +40 -0
- package/src/relations.js +62 -24
- package/src/server.js +108 -100
- package/src/utils.js +7 -18
- package/types/index.d.ts +2 -0
- package/types/src/constants.d.ts +4 -0
- package/types/src/database.d.ts +8 -0
- package/types/src/openapi/config.d.ts +3 -0
- package/types/src/openapi/document.d.ts +11 -0
- package/types/src/openapi/index.d.ts +13 -0
- package/types/src/openapi/inference.d.ts +9 -0
- package/types/src/query/filter.d.ts +3 -0
- package/types/src/query/index.d.ts +3 -0
- package/types/src/query/pagination.d.ts +13 -0
- package/types/src/query/sort.d.ts +1 -0
- package/types/src/relation-metadata.d.ts +9 -0
- package/types/src/relations.d.ts +3 -0
- package/types/src/server.d.ts +24 -0
- package/types/src/utils.d.ts +10 -0
- package/src/openapi.js +0 -520
package/src/openapi.js
DELETED
|
@@ -1,520 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname, resolve } from 'node:path';
|
|
3
|
-
import { stringify } from 'yaml';
|
|
4
|
-
import { getResourceNames, isObject, singularize, toPascalCase, validateDatabase } from './utils.js';
|
|
5
|
-
|
|
6
|
-
const readJson = async(path, label) => {
|
|
7
|
-
const value = JSON.parse(await readFile(resolve(path), 'utf8'));
|
|
8
|
-
|
|
9
|
-
if (!isObject(value)) {
|
|
10
|
-
throw new Error(`${label} должен содержать JSON-объект`);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
return value;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const getServerUrl = (host, port) => {
|
|
17
|
-
const serverPort = Number(port);
|
|
18
|
-
|
|
19
|
-
if (typeof host !== 'string' || host === '') {
|
|
20
|
-
throw new Error('Адрес сервера не должен быть пустым');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65_535) {
|
|
24
|
-
throw new Error('Порт должен быть целым числом от 1 до 65535');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const serverHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
28
|
-
|
|
29
|
-
return `http://${serverHost}:${serverPort}`;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const mergeSchemas = (schemas) => {
|
|
33
|
-
const uniqueSchemas = [...new Map(schemas.map((schema) => [JSON.stringify(schema), schema])).values()];
|
|
34
|
-
const nullable = uniqueSchemas.some((schema) => schema.type === 'null');
|
|
35
|
-
const nonNullSchemas = uniqueSchemas.filter((schema) => schema.type !== 'null');
|
|
36
|
-
|
|
37
|
-
if (nonNullSchemas.length === 0) {
|
|
38
|
-
return { nullable: true };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (nonNullSchemas.length === 1) {
|
|
42
|
-
return nullable ? { ...nonNullSchemas[0], nullable: true } : nonNullSchemas[0];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return { oneOf: nonNullSchemas, ...(nullable && { nullable: true }) };
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const mergeSchemaOverrides = (schema, overrides) => {
|
|
49
|
-
if (!isObject(overrides)) {
|
|
50
|
-
return schema;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const result = { ...schema, ...overrides };
|
|
54
|
-
|
|
55
|
-
if (Object.hasOwn(overrides, 'type') && !Object.hasOwn(overrides, 'oneOf')) {
|
|
56
|
-
delete result.oneOf;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (isObject(schema.properties) || isObject(overrides.properties)) {
|
|
60
|
-
const properties = isObject(schema.properties) ? { ...schema.properties } : {};
|
|
61
|
-
|
|
62
|
-
Object.entries(isObject(overrides.properties) ? overrides.properties : {}).forEach(([key, value]) => {
|
|
63
|
-
properties[key] = mergeSchemaOverrides(properties[key] ?? {}, value);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
result.properties = properties;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (isObject(schema.items) && isObject(overrides.items)) {
|
|
70
|
-
result.items = mergeSchemaOverrides(schema.items, overrides.items);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return result;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const applyRequiredFields = (schema, path, requiredFields) => {
|
|
77
|
-
if (Array.isArray(schema.oneOf)) {
|
|
78
|
-
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => applyRequiredFields(nestedSchema, path, requiredFields)) };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (schema.type === 'array') {
|
|
82
|
-
return { ...schema, items: applyRequiredFields(schema.items ?? {}, path, requiredFields) };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (schema.type !== 'object' || !isObject(schema.properties)) {
|
|
86
|
-
return schema;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const properties = Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => {
|
|
90
|
-
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
91
|
-
|
|
92
|
-
return [key, applyRequiredFields(value, fieldPath, requiredFields)];
|
|
93
|
-
}));
|
|
94
|
-
const required = Object.keys(properties).filter((key) => {
|
|
95
|
-
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
96
|
-
|
|
97
|
-
return path === '' && key === 'id' || requiredFields.has(fieldPath);
|
|
98
|
-
});
|
|
99
|
-
const result = { ...schema, properties };
|
|
100
|
-
|
|
101
|
-
delete result.required;
|
|
102
|
-
|
|
103
|
-
return required.length === 0 ? result : { ...result, required };
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const inferSchema = (values) => {
|
|
107
|
-
const schemas = [];
|
|
108
|
-
const arrays = values.filter(Array.isArray);
|
|
109
|
-
const objects = values.filter(isObject);
|
|
110
|
-
|
|
111
|
-
if (arrays.length > 0) {
|
|
112
|
-
const items = arrays.flat();
|
|
113
|
-
|
|
114
|
-
schemas.push({ items: items.length === 0 ? {} : inferSchema(items), type: 'array' });
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (objects.length > 0) {
|
|
118
|
-
schemas.push(inferObjectSchema(objects));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
values.filter((value) => !Array.isArray(value) && !isObject(value)).forEach((value) => {
|
|
122
|
-
if (value === null) {
|
|
123
|
-
schemas.push({ type: 'null' });
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (typeof value === 'number') {
|
|
128
|
-
schemas.push({ type: Number.isInteger(value) ? 'integer' : 'number' });
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
schemas.push({ type: typeof value });
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
return mergeSchemas(schemas);
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
function inferObjectSchema(values) {
|
|
139
|
-
const keys = [...new Set(values.flatMap((value) => Object.keys(value)))].sort((left, right) => left === 'id' ? -1 : right === 'id' ? 1 : left.localeCompare(right));
|
|
140
|
-
const properties = Object.fromEntries(keys.map((key) => {
|
|
141
|
-
const fieldValues = values.filter((value) => Object.hasOwn(value, key)).map((value) => value[key]);
|
|
142
|
-
|
|
143
|
-
return [key, inferSchema(fieldValues)];
|
|
144
|
-
}));
|
|
145
|
-
|
|
146
|
-
return { properties, type: 'object' };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const resolveRelationResource = (resources, relation, sourceResource) => {
|
|
150
|
-
const resource = resources.find((resourceName) => resourceName === relation) ?? resources.find((resourceName) => singularize(resourceName) === relation);
|
|
151
|
-
|
|
152
|
-
if (resource != null) {
|
|
153
|
-
return resource;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return ['child', 'children', 'parent', 'parents'].includes(relation) ? sourceResource : undefined;
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const addRelationSchemas = (schema, resources, componentNames, sourceResource) => {
|
|
160
|
-
if (Array.isArray(schema.oneOf)) {
|
|
161
|
-
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => addRelationSchemas(nestedSchema, resources, componentNames, sourceResource)) };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (schema.type === 'array') {
|
|
165
|
-
return { ...schema, items: addRelationSchemas(schema.items, resources, componentNames, sourceResource) };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (schema.type !== 'object' || schema.properties == null) {
|
|
169
|
-
return schema;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const properties = Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, addRelationSchemas(value, resources, componentNames, sourceResource)]));
|
|
173
|
-
|
|
174
|
-
Object.keys(schema.properties).forEach((key) => {
|
|
175
|
-
const match = key.match(/^(.+)(Id|Ids)$/);
|
|
176
|
-
|
|
177
|
-
if (match == null) {
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const [, relation, suffix] = match;
|
|
182
|
-
const relationName = suffix === 'Ids' ? resources.find((resource) => singularize(resource) === relation) ?? `${relation}s` : relation;
|
|
183
|
-
const targetResource = resolveRelationResource(resources, relationName, sourceResource);
|
|
184
|
-
|
|
185
|
-
if (targetResource == null) {
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const reference = { $ref: `#/components/schemas/${componentNames[targetResource]}` };
|
|
190
|
-
|
|
191
|
-
properties[relationName] = suffix === 'Ids' ? { items: reference, type: 'array' } : reference;
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
return { ...schema, properties };
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
const getSchemasAtPath = (schema, keys) => {
|
|
198
|
-
if (Array.isArray(schema.oneOf)) {
|
|
199
|
-
return schema.oneOf.flatMap((nestedSchema) => getSchemasAtPath(nestedSchema, keys));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (schema.type === 'array') {
|
|
203
|
-
return getSchemasAtPath(schema.items ?? {}, keys);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (keys.length === 0) {
|
|
207
|
-
return [schema];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (schema.type !== 'object' || !isObject(schema.properties) || !Object.hasOwn(schema.properties, keys[0])) {
|
|
211
|
-
return [];
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return getSchemasAtPath(schema.properties[keys[0]], keys.slice(1));
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
const updateSchemasAtPath = (schema, keys, update) => {
|
|
218
|
-
if (Array.isArray(schema.oneOf)) {
|
|
219
|
-
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => updateSchemasAtPath(nestedSchema, keys, update)) };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (schema.type === 'array') {
|
|
223
|
-
return { ...schema, items: updateSchemasAtPath(schema.items ?? {}, keys, update) };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (keys.length === 0) {
|
|
227
|
-
return update(schema);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (schema.type !== 'object' || !isObject(schema.properties) || !Object.hasOwn(schema.properties, keys[0])) {
|
|
231
|
-
return schema;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return {
|
|
235
|
-
...schema,
|
|
236
|
-
properties: {
|
|
237
|
-
...schema.properties,
|
|
238
|
-
[keys[0]]: updateSchemasAtPath(schema.properties[keys[0]], keys.slice(1), update),
|
|
239
|
-
},
|
|
240
|
-
};
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
const validateSchemaOverride = (schema, path) => {
|
|
244
|
-
if (!isObject(schema)) {
|
|
245
|
-
throw new Error(`OpenAPI-схема свойства «${path}» должна содержать JSON-объект`);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (schema.properties != null) {
|
|
249
|
-
if (!isObject(schema.properties)) {
|
|
250
|
-
throw new Error(`properties свойства «${path}» должен содержать JSON-объект`);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
Object.entries(schema.properties).forEach(([key, value]) => validateSchemaOverride(value, `${path}.${key}`));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (schema.items != null) {
|
|
257
|
-
validateSchemaOverride(schema.items, `${path}[]`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (schema.oneOf != null) {
|
|
261
|
-
if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) {
|
|
262
|
-
throw new Error(`oneOf свойства «${path}» должен содержать непустой массив`);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
schema.oneOf.forEach((value, index) => validateSchemaOverride(value, `${path}.oneOf[${index}]`));
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
const validateSchemaConfig = (schemaConfig, resources) => {
|
|
270
|
-
if (Object.hasOwn(schemaConfig, '$info')) {
|
|
271
|
-
if (!isObject(schemaConfig.$info) || !['title', 'version'].every((key) => typeof schemaConfig.$info[key] === 'string' && schemaConfig.$info[key].trim() !== '')) {
|
|
272
|
-
throw new Error('$info должен содержать непустые строковые поля title и version');
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (Object.hasOwn(schemaConfig, '$schema') && !isObject(schemaConfig.$schema)) {
|
|
277
|
-
throw new Error('$schema должен содержать JSON-объект');
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const resourceConfigs = schemaConfig.$schema ?? {};
|
|
281
|
-
|
|
282
|
-
Object.entries(resourceConfigs).forEach(([resource, resourceConfig]) => {
|
|
283
|
-
if (!resources.includes(resource)) {
|
|
284
|
-
throw new Error(`В $schema указан неизвестный ресурс «${resource}»`);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (!isObject(resourceConfig)) {
|
|
288
|
-
throw new Error(`Настройки ресурса «${resource}» должны содержать JSON-объект`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (resourceConfig.name != null && (typeof resourceConfig.name !== 'string' || resourceConfig.name.trim() === '')) {
|
|
292
|
-
throw new Error(`$schema.${resource}.name должен содержать непустую строку`);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (resourceConfig.required != null && (!Array.isArray(resourceConfig.required) || resourceConfig.required.some((path) => typeof path !== 'string' || path === ''))) {
|
|
296
|
-
throw new Error(`$schema.${resource}.required должен содержать массив непустых строк`);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (resourceConfig.formats != null && (!isObject(resourceConfig.formats) || Object.entries(resourceConfig.formats).some(([path, format]) => path === '' || typeof format !== 'string' || format === ''))) {
|
|
300
|
-
throw new Error(`$schema.${resource}.formats должен содержать JSON-объект с непустыми строковыми путями и форматами`);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (resourceConfig.properties != null) {
|
|
304
|
-
if (!isObject(resourceConfig.properties)) {
|
|
305
|
-
throw new Error(`$schema.${resource}.properties должен содержать JSON-объект`);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
Object.entries(resourceConfig.properties).forEach(([key, value]) => validateSchemaOverride(value, `${resource}.${key}`));
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
return resourceConfigs;
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
const applyConfiguredFields = (schema, resource, resourceConfig) => {
|
|
316
|
-
const requiredFields = Array.isArray(resourceConfig.required) ? resourceConfig.required : [];
|
|
317
|
-
const formats = isObject(resourceConfig.formats) ? resourceConfig.formats : {};
|
|
318
|
-
|
|
319
|
-
[...requiredFields, ...Object.keys(formats)].forEach((path) => {
|
|
320
|
-
if (getSchemasAtPath(schema, path.split('.')).length === 0) {
|
|
321
|
-
throw new Error(`Путь «${path}» из настроек ресурса «${resource}» отсутствует в итоговой схеме`);
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
const schemaWithFormats = Object.entries(formats).reduce((result, [path, format]) => {
|
|
326
|
-
const schemas = getSchemasAtPath(result, path.split('.'));
|
|
327
|
-
|
|
328
|
-
if (!schemas.some((nestedSchema) => nestedSchema.type === 'string')) {
|
|
329
|
-
throw new Error(`Формат «${format}» для пути «${resource}.${path}» можно применить только к строковому полю`);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
return updateSchemasAtPath(result, path.split('.'), (nestedSchema) => nestedSchema.type === 'string' ? { ...nestedSchema, format } : nestedSchema);
|
|
333
|
-
}, schema);
|
|
334
|
-
|
|
335
|
-
return applyRequiredFields(schemaWithFormats, '', new Set(requiredFields));
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
const omitId = (schema, keepRequired) => {
|
|
339
|
-
const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).filter(([key]) => key !== 'id'));
|
|
340
|
-
const required = keepRequired ? schema.required?.filter((key) => key !== 'id') : undefined;
|
|
341
|
-
|
|
342
|
-
const result = { ...schema, properties };
|
|
343
|
-
|
|
344
|
-
delete result.required;
|
|
345
|
-
|
|
346
|
-
return required?.length > 0 ? { ...result, required } : result;
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
const createParameters = () => ({
|
|
350
|
-
Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
|
|
351
|
-
Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
|
|
352
|
-
Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
|
|
353
|
-
PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: 10, minimum: 1, type: 'integer' } },
|
|
354
|
-
Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
|
|
355
|
-
Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const jsonContent = (schema) => ({ content: { 'application/json': { schema } } });
|
|
359
|
-
const response = (description, schema) => ({ description, ...(schema != null && jsonContent(schema)) });
|
|
360
|
-
const reference = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
361
|
-
const parameter = (name) => ({ $ref: `#/components/parameters/${name}` });
|
|
362
|
-
|
|
363
|
-
const createResourcePaths = (resource, componentName) => {
|
|
364
|
-
const resourceName = toPascalCase(resource);
|
|
365
|
-
const body = (name) => ({ required: true, ...jsonContent(reference(name)) });
|
|
366
|
-
|
|
367
|
-
return {
|
|
368
|
-
[`/${resource}`]: {
|
|
369
|
-
get: {
|
|
370
|
-
operationId: `get${resourceName}`,
|
|
371
|
-
parameters: ['Page', 'PerPage', 'Sort', 'Where', 'Embed'].map(parameter),
|
|
372
|
-
responses: { 200: response('Successful response', reference(`${componentName}Page`)), 400: response('Invalid query', reference('Error')) },
|
|
373
|
-
tags: [resource],
|
|
374
|
-
},
|
|
375
|
-
post: {
|
|
376
|
-
operationId: `post${resourceName}`,
|
|
377
|
-
requestBody: body(`${componentName}Create`),
|
|
378
|
-
responses: { 201: response('Created', reference(componentName)), 400: response('Invalid request', reference('Error')) },
|
|
379
|
-
tags: [resource],
|
|
380
|
-
},
|
|
381
|
-
},
|
|
382
|
-
[`/${resource}/{id}`]: {
|
|
383
|
-
delete: {
|
|
384
|
-
operationId: `delete${resourceName}ById`,
|
|
385
|
-
parameters: [parameter('Id')],
|
|
386
|
-
responses: { 200: response('Deleted', reference(componentName)), 404: response('Not found', reference('Error')) },
|
|
387
|
-
tags: [resource],
|
|
388
|
-
},
|
|
389
|
-
get: {
|
|
390
|
-
operationId: `get${resourceName}ById`,
|
|
391
|
-
parameters: [parameter('Id'), parameter('Embed')],
|
|
392
|
-
responses: { 200: response('Successful response', reference(componentName)), 404: response('Not found', reference('Error')) },
|
|
393
|
-
tags: [resource],
|
|
394
|
-
},
|
|
395
|
-
patch: {
|
|
396
|
-
operationId: `patch${resourceName}ById`,
|
|
397
|
-
parameters: [parameter('Id')],
|
|
398
|
-
requestBody: body(`${componentName}Update`),
|
|
399
|
-
responses: { 200: response('Updated', reference(componentName)), 404: response('Not found', reference('Error')) },
|
|
400
|
-
tags: [resource],
|
|
401
|
-
},
|
|
402
|
-
put: {
|
|
403
|
-
operationId: `put${resourceName}ById`,
|
|
404
|
-
parameters: [parameter('Id')],
|
|
405
|
-
requestBody: body(`${componentName}Create`),
|
|
406
|
-
responses: { 200: response('Replaced', reference(componentName)), 404: response('Not found', reference('Error')) },
|
|
407
|
-
tags: [resource],
|
|
408
|
-
},
|
|
409
|
-
},
|
|
410
|
-
};
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
const getOperationIds = (resource) => {
|
|
414
|
-
const resourceName = toPascalCase(resource);
|
|
415
|
-
|
|
416
|
-
return [`get${resourceName}`, `post${resourceName}`, `delete${resourceName}ById`, `get${resourceName}ById`, `patch${resourceName}ById`, `put${resourceName}ById`];
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
const validateGeneratedNames = (resources, componentNames) => {
|
|
420
|
-
const schemaOwners = new Map([['Error', 'встроенная схема ошибки']]);
|
|
421
|
-
const operationOwners = new Map();
|
|
422
|
-
|
|
423
|
-
resources.forEach((resource) => {
|
|
424
|
-
const componentName = componentNames[resource];
|
|
425
|
-
|
|
426
|
-
if (componentName === '') {
|
|
427
|
-
throw new Error(`Не удалось сформировать имя OpenAPI-схемы для ресурса «${resource}». Укажите $schema.${resource}.name`);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
[componentName, `${componentName}Create`, `${componentName}Update`, `${componentName}Page`].forEach((schemaName) => {
|
|
431
|
-
const owner = schemaOwners.get(schemaName);
|
|
432
|
-
|
|
433
|
-
if (owner != null) {
|
|
434
|
-
throw new Error(`Имя OpenAPI-схемы «${schemaName}» используется ресурсами «${owner}» и «${resource}». Укажите уникальный $schema.${resource}.name`);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
schemaOwners.set(schemaName, resource);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
getOperationIds(resource).forEach((operationId) => {
|
|
441
|
-
const owner = operationOwners.get(operationId);
|
|
442
|
-
|
|
443
|
-
if (owner != null) {
|
|
444
|
-
throw new Error(`operationId «${operationId}» используется ресурсами «${owner}» и «${resource}». Переименуйте один из ресурсов`);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
operationOwners.set(operationId, resource);
|
|
448
|
-
});
|
|
449
|
-
});
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
export function createOpenApiDocument(database, schemaConfig = {}, { host = '127.0.0.1', port = 4001 } = {}) {
|
|
453
|
-
validateDatabase(database);
|
|
454
|
-
|
|
455
|
-
if (!isObject(schemaConfig)) {
|
|
456
|
-
throw new Error('Схема базы данных должна содержать JSON-объект');
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const resources = getResourceNames(database);
|
|
460
|
-
const resourceConfigs = validateSchemaConfig(schemaConfig, resources);
|
|
461
|
-
const componentNames = Object.fromEntries(resources.map((resource) => {
|
|
462
|
-
const resourceConfig = isObject(resourceConfigs[resource]) ? resourceConfigs[resource] : {};
|
|
463
|
-
const componentName = typeof resourceConfig.name === 'string' && resourceConfig.name !== '' ? resourceConfig.name : toPascalCase(singularize(resource));
|
|
464
|
-
|
|
465
|
-
return [resource, componentName];
|
|
466
|
-
}));
|
|
467
|
-
|
|
468
|
-
validateGeneratedNames(resources, componentNames);
|
|
469
|
-
|
|
470
|
-
const schemas = {
|
|
471
|
-
Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
|
|
472
|
-
};
|
|
473
|
-
|
|
474
|
-
resources.forEach((resource) => {
|
|
475
|
-
const componentName = componentNames[resource];
|
|
476
|
-
const resourceConfig = isObject(resourceConfigs[resource]) ? resourceConfigs[resource] : {};
|
|
477
|
-
const values = database[resource].filter(isObject);
|
|
478
|
-
const inferredSchema = values.length === 0 ? { properties: { id: { type: 'string' } }, type: 'object' } : inferObjectSchema(values);
|
|
479
|
-
const rawSchema = applyConfiguredFields(mergeSchemaOverrides(inferredSchema, { properties: isObject(resourceConfig.properties) ? resourceConfig.properties : {} }), resource, resourceConfig);
|
|
480
|
-
const responseSchema = addRelationSchemas(rawSchema, resources, componentNames, resource);
|
|
481
|
-
|
|
482
|
-
schemas[componentName] = responseSchema;
|
|
483
|
-
schemas[`${componentName}Create`] = omitId(rawSchema, true);
|
|
484
|
-
schemas[`${componentName}Update`] = omitId(rawSchema, false);
|
|
485
|
-
schemas[`${componentName}Page`] = {
|
|
486
|
-
properties: {
|
|
487
|
-
data: { items: reference(componentName), type: 'array' },
|
|
488
|
-
first: { type: 'integer' },
|
|
489
|
-
items: { type: 'integer' },
|
|
490
|
-
last: { type: 'integer' },
|
|
491
|
-
next: { nullable: true, type: 'integer' },
|
|
492
|
-
pages: { type: 'integer' },
|
|
493
|
-
prev: { nullable: true, type: 'integer' },
|
|
494
|
-
},
|
|
495
|
-
required: ['data', 'first', 'items', 'last', 'next', 'pages', 'prev'],
|
|
496
|
-
type: 'object',
|
|
497
|
-
};
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
return {
|
|
501
|
-
components: { parameters: createParameters(), schemas },
|
|
502
|
-
info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
|
|
503
|
-
openapi: '3.0.3',
|
|
504
|
-
paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource]))),
|
|
505
|
-
servers: [{ url: getServerUrl(host, port) }],
|
|
506
|
-
tags: resources.map((resource) => ({ name: resource })),
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
export async function generateOpenApi({ databasePath, host, outputPath, port, schemaPath }) {
|
|
511
|
-
const database = await readJson(databasePath, 'База данных');
|
|
512
|
-
const schemaConfig = await readJson(schemaPath, 'Схема базы данных');
|
|
513
|
-
const document = createOpenApiDocument(database, schemaConfig, { host, port });
|
|
514
|
-
const resolvedOutputPath = resolve(outputPath);
|
|
515
|
-
|
|
516
|
-
await mkdir(dirname(resolvedOutputPath), { recursive: true });
|
|
517
|
-
await writeFile(resolvedOutputPath, stringify(document, { aliasDuplicateObjects: false, lineWidth: 0 }), 'utf8');
|
|
518
|
-
|
|
519
|
-
return document;
|
|
520
|
-
}
|