@kollors/deep-json-server 0.3.2 → 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 +206 -37
- package/README.ru.md +206 -37
- package/bin/deep-json-server.js +11 -0
- package/index.js +1 -11
- package/package.json +22 -5
- package/src/cli.js +67 -51
- package/src/config.js +97 -0
- package/src/constants.js +5 -0
- package/src/database.js +128 -0
- package/src/files.js +278 -0
- package/src/openapi/config.js +110 -0
- package/src/openapi/document.js +331 -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 +132 -101
- package/src/utils.js +7 -18
- package/types/index.d.ts +2 -0
- package/types/src/constants.d.ts +5 -0
- package/types/src/database.d.ts +8 -0
- package/types/src/files.d.ts +5 -0
- package/types/src/openapi/config.d.ts +3 -0
- package/types/src/openapi/document.d.ts +12 -0
- package/types/src/openapi/index.d.ts +14 -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 +30 -0
- package/types/src/utils.d.ts +10 -0
- package/src/openapi.js +0 -520
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { DEFAULT_HOST, DEFAULT_PAGE_SIZE, DEFAULT_PORT, MAX_PAGE_SIZE } from '../constants.js';
|
|
2
|
+
import { validateDatabase } from '../database.js';
|
|
3
|
+
import { getRelationMetadata } from '../relation-metadata.js';
|
|
4
|
+
import { getResourceNames, isObject, singularize, toPascalCase } from '../utils.js';
|
|
5
|
+
import { applyConfiguredFields, validateSchemaConfig } from './config.js';
|
|
6
|
+
import { ensureGeneratedIdSchema, inferObjectSchema, mergeSchemaOverrides, omitId } from './inference.js';
|
|
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
|
+
const createSchemaReference = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
25
|
+
|
|
26
|
+
const addForwardRelations = (schema, resources, componentNames, sourceResource) => {
|
|
27
|
+
if (Array.isArray(schema.oneOf)) {
|
|
28
|
+
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => addForwardRelations(nestedSchema, resources, componentNames, sourceResource)) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (schema.type === 'array') {
|
|
32
|
+
return { ...schema, items: addForwardRelations(schema.items ?? {}, resources, componentNames, sourceResource) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (schema.type !== 'object' || !isObject(schema.properties)) {
|
|
36
|
+
return schema;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const properties = Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, addForwardRelations(value, resources, componentNames, sourceResource)]));
|
|
40
|
+
|
|
41
|
+
Object.keys(schema.properties).forEach((key) => {
|
|
42
|
+
const relation = getRelationMetadata(key, resources, sourceResource);
|
|
43
|
+
|
|
44
|
+
if (relation != null) {
|
|
45
|
+
const relationSchema = createSchemaReference(componentNames[relation.targetResource]);
|
|
46
|
+
|
|
47
|
+
properties[relation.relationName] = relation.isMany ? { items: relationSchema, type: 'array' } : relationSchema;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return { ...schema, properties };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const collectRelations = (schema, resources, sourceResource, relations = []) => {
|
|
55
|
+
if (Array.isArray(schema.oneOf)) {
|
|
56
|
+
schema.oneOf.forEach((nestedSchema) => {
|
|
57
|
+
collectRelations(nestedSchema, resources, sourceResource, relations);
|
|
58
|
+
});
|
|
59
|
+
return relations;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (schema.type === 'array') {
|
|
63
|
+
collectRelations(schema.items ?? {}, resources, sourceResource, relations);
|
|
64
|
+
return relations;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (schema.type !== 'object' || !isObject(schema.properties)) {
|
|
68
|
+
return relations;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Object.entries(schema.properties).forEach(([key, value]) => {
|
|
72
|
+
const relation = getRelationMetadata(key, resources, sourceResource);
|
|
73
|
+
|
|
74
|
+
if (relation != null) {
|
|
75
|
+
relations.push(relation);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
collectRelations(value, resources, sourceResource, relations);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return relations;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const addReverseRelations = (schemas, rawSchemas, resources, componentNames) => {
|
|
85
|
+
resources.forEach((sourceResource) => {
|
|
86
|
+
collectRelations(rawSchemas[sourceResource], resources, sourceResource).forEach(({ reverseRelationName, targetResource }) => {
|
|
87
|
+
const targetComponentName = componentNames[targetResource];
|
|
88
|
+
const targetSchema = schemas[targetComponentName];
|
|
89
|
+
|
|
90
|
+
if (targetSchema.type === 'object' && isObject(targetSchema.properties) && !Object.hasOwn(targetSchema.properties, reverseRelationName)) {
|
|
91
|
+
schemas[targetComponentName] = {
|
|
92
|
+
...targetSchema,
|
|
93
|
+
properties: {
|
|
94
|
+
...targetSchema.properties,
|
|
95
|
+
[reverseRelationName]: { items: createSchemaReference(componentNames[sourceResource]), type: 'array' },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const createParameters = () => ({
|
|
104
|
+
ContentName: { description: 'URI-encoded relative file name', in: 'header', name: 'Content-Name', required: true, schema: { type: 'string' } },
|
|
105
|
+
Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
|
|
106
|
+
Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
|
|
107
|
+
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: MAX_PAGE_SIZE, minimum: 1, type: 'integer' } },
|
|
109
|
+
Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
|
|
110
|
+
Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const createJsonContent = (schema) => ({ content: { 'application/json': { schema } } });
|
|
114
|
+
const createResponse = (description, schema) => ({ description, ...(schema != null && createJsonContent(schema)) });
|
|
115
|
+
const createParameterReference = (name) => ({ $ref: `#/components/parameters/${name}` });
|
|
116
|
+
const createRequestBody = (name) => ({ required: true, ...createJsonContent(createSchemaReference(name)) });
|
|
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
|
+
|
|
152
|
+
const createResourcePaths = (resource, componentName) => {
|
|
153
|
+
const resourceName = toPascalCase(resource);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
[`/${resource}`]: {
|
|
157
|
+
get: {
|
|
158
|
+
operationId: `get${resourceName}`,
|
|
159
|
+
parameters: ['Page', 'PerPage', 'Sort', 'Where', 'Embed'].map(createParameterReference),
|
|
160
|
+
responses: { 200: createResponse('Successful response', createSchemaReference(`${componentName}Page`)), 400: createResponse('Invalid query', createSchemaReference('Error')) },
|
|
161
|
+
tags: [resource],
|
|
162
|
+
},
|
|
163
|
+
post: {
|
|
164
|
+
operationId: `post${resourceName}`,
|
|
165
|
+
requestBody: createRequestBody(`${componentName}Create`),
|
|
166
|
+
responses: { 201: createResponse('Created', createSchemaReference(componentName)), 400: createResponse('Invalid request', createSchemaReference('Error')) },
|
|
167
|
+
tags: [resource],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
[`/${resource}/{id}`]: {
|
|
171
|
+
delete: {
|
|
172
|
+
operationId: `delete${resourceName}ById`,
|
|
173
|
+
parameters: [createParameterReference('Id')],
|
|
174
|
+
responses: { 200: createResponse('Deleted', createSchemaReference(componentName)), 404: createResponse('Not found', createSchemaReference('Error')) },
|
|
175
|
+
tags: [resource],
|
|
176
|
+
},
|
|
177
|
+
get: {
|
|
178
|
+
operationId: `get${resourceName}ById`,
|
|
179
|
+
parameters: [createParameterReference('Id'), createParameterReference('Embed')],
|
|
180
|
+
responses: {
|
|
181
|
+
200: createResponse('Successful response', createSchemaReference(componentName)),
|
|
182
|
+
400: createResponse('Invalid query', createSchemaReference('Error')),
|
|
183
|
+
404: createResponse('Not found', createSchemaReference('Error')),
|
|
184
|
+
},
|
|
185
|
+
tags: [resource],
|
|
186
|
+
},
|
|
187
|
+
patch: {
|
|
188
|
+
operationId: `patch${resourceName}ById`,
|
|
189
|
+
parameters: [createParameterReference('Id')],
|
|
190
|
+
requestBody: createRequestBody(`${componentName}Update`),
|
|
191
|
+
responses: {
|
|
192
|
+
200: createResponse('Updated', createSchemaReference(componentName)),
|
|
193
|
+
400: createResponse('Invalid request', createSchemaReference('Error')),
|
|
194
|
+
404: createResponse('Not found', createSchemaReference('Error')),
|
|
195
|
+
},
|
|
196
|
+
tags: [resource],
|
|
197
|
+
},
|
|
198
|
+
put: {
|
|
199
|
+
operationId: `put${resourceName}ById`,
|
|
200
|
+
parameters: [createParameterReference('Id')],
|
|
201
|
+
requestBody: createRequestBody(`${componentName}Create`),
|
|
202
|
+
responses: {
|
|
203
|
+
200: createResponse('Replaced', createSchemaReference(componentName)),
|
|
204
|
+
400: createResponse('Invalid request', createSchemaReference('Error')),
|
|
205
|
+
404: createResponse('Not found', createSchemaReference('Error')),
|
|
206
|
+
},
|
|
207
|
+
tags: [resource],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
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
|
+
const validateGeneratedNames = (resources, componentNames, files) => {
|
|
220
|
+
const schemaOwners = new Map([['Error', 'встроенная схема ошибки'], ...(files ? [['UploadedFile', 'встроенная схема файла']] : [])]);
|
|
221
|
+
const operationOwners = new Map();
|
|
222
|
+
|
|
223
|
+
resources.forEach((resource) => {
|
|
224
|
+
const componentName = componentNames[resource];
|
|
225
|
+
|
|
226
|
+
if (componentName === '') {
|
|
227
|
+
throw new Error(`Не удалось сформировать имя OpenAPI-схемы для ресурса «${resource}». Укажите $schema.${resource}.name`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
[componentName, `${componentName}Create`, `${componentName}Update`, `${componentName}Page`].forEach((schemaName) => {
|
|
231
|
+
const owner = schemaOwners.get(schemaName);
|
|
232
|
+
|
|
233
|
+
if (owner != null) {
|
|
234
|
+
throw new Error(`Имя OpenAPI-схемы «${schemaName}» используется ресурсами «${owner}» и «${resource}». Укажите уникальный $schema.${resource}.name`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
schemaOwners.set(schemaName, resource);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
getOperationIds(resource).forEach((operationId) => {
|
|
241
|
+
const owner = operationOwners.get(operationId);
|
|
242
|
+
|
|
243
|
+
if (owner != null) {
|
|
244
|
+
throw new Error(`operationId «${operationId}» используется ресурсами «${owner}» и «${resource}». Переименуйте один из ресурсов`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
operationOwners.set(operationId, resource);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Creates an OpenAPI 3.0 document from a database and optional schema configuration.
|
|
254
|
+
* @param {Record<string, Array<Record<string, unknown>>>} database Database contents.
|
|
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.
|
|
257
|
+
* @returns {Record<string, unknown>} OpenAPI document.
|
|
258
|
+
*/
|
|
259
|
+
export function createOpenApiDocument(database, schemaConfig = {}, { files = false, host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
|
|
260
|
+
validateDatabase(database);
|
|
261
|
+
|
|
262
|
+
if (!isObject(schemaConfig)) {
|
|
263
|
+
throw new Error('Схема базы данных должна содержать JSON-объект');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const resources = getResourceNames(database);
|
|
267
|
+
const resourceConfigs = validateSchemaConfig(schemaConfig, resources);
|
|
268
|
+
const componentNames = Object.fromEntries(
|
|
269
|
+
resources.map((resource) => {
|
|
270
|
+
const resourceConfig = isObject(resourceConfigs[resource]) ? resourceConfigs[resource] : {};
|
|
271
|
+
const componentName = typeof resourceConfig.name === 'string' && resourceConfig.name !== '' ? resourceConfig.name : toPascalCase(singularize(resource));
|
|
272
|
+
|
|
273
|
+
return [resource, componentName];
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
validateGeneratedNames(resources, componentNames, files);
|
|
278
|
+
|
|
279
|
+
const rawSchemas = Object.fromEntries(
|
|
280
|
+
resources.map((resource) => {
|
|
281
|
+
const resourceConfig = isObject(resourceConfigs[resource]) ? resourceConfigs[resource] : {};
|
|
282
|
+
const inferredSchema = database[resource].length === 0 ? { properties: { id: { type: 'string' } }, type: 'object' } : inferObjectSchema(database[resource]);
|
|
283
|
+
const configuredSchema = mergeSchemaOverrides(inferredSchema, { properties: isObject(resourceConfig.properties) ? resourceConfig.properties : {} });
|
|
284
|
+
|
|
285
|
+
return [resource, applyConfiguredFields(ensureGeneratedIdSchema(configuredSchema), resource, resourceConfig)];
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
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
|
+
};
|
|
298
|
+
|
|
299
|
+
resources.forEach((resource) => {
|
|
300
|
+
const componentName = componentNames[resource];
|
|
301
|
+
const rawSchema = rawSchemas[resource];
|
|
302
|
+
|
|
303
|
+
schemas[componentName] = addForwardRelations(rawSchema, resources, componentNames, resource);
|
|
304
|
+
schemas[`${componentName}Create`] = omitId(rawSchema, true);
|
|
305
|
+
schemas[`${componentName}Update`] = omitId(rawSchema, false);
|
|
306
|
+
schemas[`${componentName}Page`] = {
|
|
307
|
+
properties: {
|
|
308
|
+
data: { items: createSchemaReference(componentName), type: 'array' },
|
|
309
|
+
first: { type: 'integer' },
|
|
310
|
+
items: { type: 'integer' },
|
|
311
|
+
last: { type: 'integer' },
|
|
312
|
+
next: { nullable: true, type: 'integer' },
|
|
313
|
+
pages: { type: 'integer' },
|
|
314
|
+
prev: { nullable: true, type: 'integer' },
|
|
315
|
+
},
|
|
316
|
+
required: ['data', 'first', 'items', 'last', 'next', 'pages', 'prev'],
|
|
317
|
+
type: 'object',
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
addReverseRelations(schemas, rawSchemas, resources, componentNames);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
components: { parameters: createParameters(), schemas },
|
|
325
|
+
info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
|
|
326
|
+
openapi: '3.0.3',
|
|
327
|
+
paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource])), files ? createFilePaths() : {}),
|
|
328
|
+
servers: [{ url: getServerUrl(host, port) }],
|
|
329
|
+
tags: [...resources.map((resource) => ({ name: resource })), ...(files ? [{ name: 'files' }] : [])],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { stringify } from 'yaml';
|
|
4
|
+
import { readDatabaseFile } from '../database.js';
|
|
5
|
+
import { readSchemaConfig } from './config.js';
|
|
6
|
+
import { createOpenApiDocument } from './document.js';
|
|
7
|
+
|
|
8
|
+
export { createOpenApiDocument } from './document.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generates an OpenAPI YAML file.
|
|
12
|
+
* @param {{ databasePath: string, files?: boolean, host?: string, outputPath: string, port?: number, schemaPath?: string }} options Generation options.
|
|
13
|
+
* @returns {Promise<Record<string, unknown>>} Generated OpenAPI document.
|
|
14
|
+
*/
|
|
15
|
+
export async function generateOpenApi({ databasePath, files = false, host, outputPath, port, schemaPath }) {
|
|
16
|
+
const database = await readDatabaseFile(databasePath);
|
|
17
|
+
const schemaConfig = await readSchemaConfig(schemaPath);
|
|
18
|
+
const document = createOpenApiDocument(database, schemaConfig, { files, host, port });
|
|
19
|
+
const resolvedOutputPath = resolve(outputPath);
|
|
20
|
+
|
|
21
|
+
await mkdir(dirname(resolvedOutputPath), { recursive: true });
|
|
22
|
+
await writeFile(resolvedOutputPath, stringify(document, { aliasDuplicateObjects: false, lineWidth: 0 }), 'utf8');
|
|
23
|
+
|
|
24
|
+
return document;
|
|
25
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { isObject } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
export const mergeSchemas = (schemas) => {
|
|
4
|
+
const uniqueSchemas = [...new Map(schemas.map((schema) => [JSON.stringify(schema), schema])).values()];
|
|
5
|
+
const nullable = uniqueSchemas.some((schema) => schema.type === 'null');
|
|
6
|
+
const nonNullSchemas = uniqueSchemas.filter((schema) => schema.type !== 'null');
|
|
7
|
+
|
|
8
|
+
if (nonNullSchemas.length === 0) {
|
|
9
|
+
return { nullable: true };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (nonNullSchemas.length === 1) {
|
|
13
|
+
return nullable ? { ...nonNullSchemas[0], nullable: true } : nonNullSchemas[0];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return { oneOf: nonNullSchemas, ...(nullable && { nullable: true }) };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const mergeSchemaOverrides = (schema, overrides) => {
|
|
20
|
+
if (!isObject(overrides)) {
|
|
21
|
+
return schema;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = { ...schema, ...overrides };
|
|
25
|
+
|
|
26
|
+
if (Object.hasOwn(overrides, 'type') && !Object.hasOwn(overrides, 'oneOf')) {
|
|
27
|
+
delete result.oneOf;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isObject(schema.properties) || isObject(overrides.properties)) {
|
|
31
|
+
const properties = isObject(schema.properties) ? { ...schema.properties } : {};
|
|
32
|
+
|
|
33
|
+
Object.entries(isObject(overrides.properties) ? overrides.properties : {}).forEach(([key, value]) => {
|
|
34
|
+
properties[key] = mergeSchemaOverrides(properties[key] ?? {}, value);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
result.properties = properties;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (isObject(schema.items) && isObject(overrides.items)) {
|
|
41
|
+
result.items = mergeSchemaOverrides(schema.items, overrides.items);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const applyRequiredFields = (schema, path, requiredFields) => {
|
|
48
|
+
if (Array.isArray(schema.oneOf)) {
|
|
49
|
+
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => applyRequiredFields(nestedSchema, path, requiredFields)) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (schema.type === 'array') {
|
|
53
|
+
return { ...schema, items: applyRequiredFields(schema.items ?? {}, path, requiredFields) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (schema.type !== 'object' || !isObject(schema.properties)) {
|
|
57
|
+
return schema;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const properties = Object.fromEntries(
|
|
61
|
+
Object.entries(schema.properties).map(([key, value]) => {
|
|
62
|
+
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
63
|
+
|
|
64
|
+
return [key, applyRequiredFields(value, fieldPath, requiredFields)];
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
const required = Object.keys(properties).filter((key) => {
|
|
68
|
+
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
69
|
+
|
|
70
|
+
return (path === '' && key === 'id') || requiredFields.has(fieldPath);
|
|
71
|
+
});
|
|
72
|
+
const result = { ...schema, properties };
|
|
73
|
+
|
|
74
|
+
delete result.required;
|
|
75
|
+
|
|
76
|
+
return required.length === 0 ? result : { ...result, required };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const inferSchema = (values) => {
|
|
80
|
+
const schemas = [];
|
|
81
|
+
const arrays = values.filter(Array.isArray);
|
|
82
|
+
const objects = values.filter(isObject);
|
|
83
|
+
|
|
84
|
+
if (arrays.length > 0) {
|
|
85
|
+
const items = arrays.flat();
|
|
86
|
+
|
|
87
|
+
schemas.push({ items: items.length === 0 ? {} : inferSchema(items), type: 'array' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (objects.length > 0) {
|
|
91
|
+
schemas.push(inferObjectSchema(objects));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
values
|
|
95
|
+
.filter((value) => !Array.isArray(value) && !isObject(value))
|
|
96
|
+
.forEach((value) => {
|
|
97
|
+
if (value === null) {
|
|
98
|
+
schemas.push({ type: 'null' });
|
|
99
|
+
} else if (typeof value === 'number') {
|
|
100
|
+
schemas.push({ type: Number.isInteger(value) ? 'integer' : 'number' });
|
|
101
|
+
} else {
|
|
102
|
+
schemas.push({ type: typeof value });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return mergeSchemas(schemas);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function inferObjectSchema(values) {
|
|
110
|
+
const keys = [...new Set(values.flatMap((value) => Object.keys(value)))].sort((left, right) => (left === 'id' ? -1 : right === 'id' ? 1 : left.localeCompare(right)));
|
|
111
|
+
const properties = Object.fromEntries(
|
|
112
|
+
keys.map((key) => {
|
|
113
|
+
const fieldValues = values.filter((value) => Object.hasOwn(value, key)).map((value) => value[key]);
|
|
114
|
+
|
|
115
|
+
return [key, inferSchema(fieldValues)];
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return { properties, type: 'object' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const ensureGeneratedIdSchema = (schema) => {
|
|
123
|
+
const idSchema = schema.properties?.id ?? {};
|
|
124
|
+
const idSchemas = Array.isArray(idSchema.oneOf) ? idSchema.oneOf : [idSchema];
|
|
125
|
+
const properties = {
|
|
126
|
+
...schema.properties,
|
|
127
|
+
id: idSchemas.some(({ type }) => type === 'string') ? idSchema : { oneOf: [...idSchemas, { type: 'string' }] },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return { ...schema, properties };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const getSchemasAtPath = (schema, keys) => {
|
|
134
|
+
if (Array.isArray(schema.oneOf)) {
|
|
135
|
+
return schema.oneOf.flatMap((nestedSchema) => getSchemasAtPath(nestedSchema, keys));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (schema.type === 'array') {
|
|
139
|
+
return getSchemasAtPath(schema.items ?? {}, keys);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (keys.length === 0) {
|
|
143
|
+
return [schema];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (schema.type !== 'object' || !isObject(schema.properties) || !Object.hasOwn(schema.properties, keys[0])) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return getSchemasAtPath(schema.properties[keys[0]], keys.slice(1));
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const updateSchemasAtPath = (schema, keys, update) => {
|
|
154
|
+
if (Array.isArray(schema.oneOf)) {
|
|
155
|
+
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => updateSchemasAtPath(nestedSchema, keys, update)) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (schema.type === 'array') {
|
|
159
|
+
return { ...schema, items: updateSchemasAtPath(schema.items ?? {}, keys, update) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (keys.length === 0) {
|
|
163
|
+
return update(schema);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (schema.type !== 'object' || !isObject(schema.properties) || !Object.hasOwn(schema.properties, keys[0])) {
|
|
167
|
+
return schema;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
...schema,
|
|
172
|
+
properties: {
|
|
173
|
+
...schema.properties,
|
|
174
|
+
[keys[0]]: updateSchemasAtPath(schema.properties[keys[0]], keys.slice(1), update),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const omitId = (schema, keepRequired) => {
|
|
180
|
+
const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).filter(([key]) => key !== 'id'));
|
|
181
|
+
const required = keepRequired ? schema.required?.filter((key) => key !== 'id') : undefined;
|
|
182
|
+
const result = { ...schema, properties };
|
|
183
|
+
|
|
184
|
+
delete result.required;
|
|
185
|
+
|
|
186
|
+
return required?.length > 0 ? { ...result, required } : result;
|
|
187
|
+
};
|