@kollors/deep-json-server 0.9.0 → 1.0.0-alpha.1

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.
Files changed (67) hide show
  1. package/README.md +286 -410
  2. package/README.ru.md +278 -404
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/src/cli.js +50 -63
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/config.d.ts +16 -2
  8. package/dist/src/config.js +24 -8
  9. package/dist/src/config.js.map +1 -1
  10. package/dist/src/constants.d.ts +1 -1
  11. package/dist/src/constants.js +1 -1
  12. package/dist/src/constants.js.map +1 -1
  13. package/dist/src/database.d.ts +4 -3
  14. package/dist/src/database.js +32 -19
  15. package/dist/src/database.js.map +1 -1
  16. package/dist/src/engine.d.ts +47 -0
  17. package/dist/src/engine.js +438 -0
  18. package/dist/src/engine.js.map +1 -0
  19. package/dist/src/files/disk-store.js +13 -2
  20. package/dist/src/files/disk-store.js.map +1 -1
  21. package/dist/src/files/openapi.d.ts +3 -0
  22. package/dist/src/files/openapi.js +86 -0
  23. package/dist/src/files/openapi.js.map +1 -0
  24. package/dist/src/graphql.d.ts +4 -0
  25. package/dist/src/graphql.js +223 -0
  26. package/dist/src/graphql.js.map +1 -0
  27. package/dist/src/model.d.ts +67 -0
  28. package/dist/src/model.js +319 -0
  29. package/dist/src/model.js.map +1 -0
  30. package/dist/src/openapi/document.d.ts +7 -7
  31. package/dist/src/openapi/document.js +234 -320
  32. package/dist/src/openapi/document.js.map +1 -1
  33. package/dist/src/openapi/index.d.ts +3 -6
  34. package/dist/src/openapi/index.js +2 -3
  35. package/dist/src/openapi/index.js.map +1 -1
  36. package/dist/src/query/filter.d.ts +0 -5
  37. package/dist/src/query/filter.js +6 -170
  38. package/dist/src/query/filter.js.map +1 -1
  39. package/dist/src/query/options.d.ts +31 -0
  40. package/dist/src/query/options.js +135 -0
  41. package/dist/src/query/options.js.map +1 -0
  42. package/dist/src/server.d.ts +4 -3
  43. package/dist/src/server.js +113 -135
  44. package/dist/src/server.js.map +1 -1
  45. package/dist/src/types.d.ts +1 -3
  46. package/dist/src/utils.d.ts +0 -1
  47. package/dist/src/utils.js +0 -1
  48. package/dist/src/utils.js.map +1 -1
  49. package/package.json +9 -4
  50. package/dist/src/openapi/config.d.ts +0 -11
  51. package/dist/src/openapi/config.js +0 -204
  52. package/dist/src/openapi/config.js.map +0 -1
  53. package/dist/src/openapi/inference.d.ts +0 -14
  54. package/dist/src/openapi/inference.js +0 -145
  55. package/dist/src/openapi/inference.js.map +0 -1
  56. package/dist/src/query/index.d.ts +0 -3
  57. package/dist/src/query/index.js +0 -4
  58. package/dist/src/query/index.js.map +0 -1
  59. package/dist/src/query/pagination.d.ts +0 -11
  60. package/dist/src/query/pagination.js +0 -28
  61. package/dist/src/query/pagination.js.map +0 -1
  62. package/dist/src/query/sort.d.ts +0 -1
  63. package/dist/src/query/sort.js +0 -54
  64. package/dist/src/query/sort.js.map +0 -1
  65. package/dist/src/relations.d.ts +0 -12
  66. package/dist/src/relations.js +0 -155
  67. package/dist/src/relations.js.map +0 -1
@@ -1,338 +1,252 @@
1
- import { DEFAULT_MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE } from '../constants.js';
2
- import { validateDatabase } from '../database.js';
3
- import { FILE_HEADERS, FILE_METADATA_SCHEMA, FILE_ROUTES, FILE_UPDATE_SCHEMA } from '../files/contract.js';
4
- import { getRelationMetadata } from '../relation-metadata.js';
5
- import { getResourceNames, isObject, singularize, toPascalCase } from '../utils.js';
6
- import { applyConfiguredFields, normalizeSchemaConfig } from './config.js';
7
- import { ensureGeneratedIdSchema, inferObjectSchema, mergeSchemaOverrides, omitId } from './inference.js';
8
- const createSchemaReference = (name) => ({ $ref: `#/components/schemas/${name}` });
9
- const addForwardRelations = (schema, resources, componentNames, sourceResource) => {
10
- if (Array.isArray(schema.oneOf)) {
11
- return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => addForwardRelations(nestedSchema, resources, componentNames, sourceResource)) };
1
+ import { FILE_HEADERS, FILE_METADATA_SCHEMA, FILE_UPDATE_SCHEMA } from '../files/contract.js';
2
+ import { createFilePaths } from '../files/openapi.js';
3
+ import { assertApi, nodeName, objectSchema, operationName, valueSchema } from '../model.js';
4
+ import { childrenOf, sortableFields } from '../query/options.js';
5
+ import { isObject } from '../utils.js';
6
+ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
7
+ const json = (schema) => ({ content: { 'application/json': { schema } } });
8
+ const response = (description, schema) => ({ description, ...json(schema) });
9
+ function toOpenapi(schema) {
10
+ if (Array.isArray(schema.anyOf) && schema.anyOf.some((v) => isObject(v) && v.type === 'null')) {
11
+ const nonNull = schema.anyOf.find((v) => isObject(v) && v.type !== 'null');
12
+ const converted = toOpenapi(nonNull);
13
+ return { ...converted, nullable: true, ...(converted.enum ? { enum: [...converted.enum.filter((value) => value !== null), null] } : {}) };
12
14
  }
13
- if (schema.type === 'array') {
14
- return { ...schema, items: addForwardRelations(schema.items ?? {}, resources, componentNames, sourceResource) };
15
+ const result = { ...schema };
16
+ if (isObject(schema.properties))
17
+ result.properties = Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k, toOpenapi(v)]));
18
+ if (isObject(schema.items))
19
+ result.items = toOpenapi(schema.items);
20
+ return result;
21
+ }
22
+ export function buildOpenapiDocument({ model, files = false, pageSize = 10, maxPageSize = 100, info = { title: 'Deep JSON Server API', version: '1.0.0-alpha.1' }, }) {
23
+ assertApi(model, 'openapi');
24
+ const schemas = {
25
+ Error: { type: 'object', properties: { error: { type: 'string' } }, required: ['error'] },
26
+ Pager: {
27
+ type: 'object',
28
+ additionalProperties: false,
29
+ properties: { page: { type: 'integer', minimum: 1, default: 1 }, pageSize: { type: 'integer', minimum: 1, maximum: maxPageSize, default: pageSize } },
30
+ },
31
+ };
32
+ const owners = new Map();
33
+ function reserve(name, node) {
34
+ if (owners.get(name) === node)
35
+ return false;
36
+ if (schemas[name])
37
+ throw new Error(`OpenAPI schema name collision: ${name}`);
38
+ owners.set(name, node);
39
+ schemas[name] = {};
40
+ return true;
15
41
  }
16
- if (schema.type !== 'object' || !isObject(schema.properties)) {
42
+ function baseField(node) {
43
+ const schema = toOpenapi(valueSchema(node));
44
+ for (const key of ['description', 'example', 'default', 'readOnly', 'writeOnly'])
45
+ if (node[key] !== undefined)
46
+ schema[key] = node[key];
47
+ if (node.generated)
48
+ schema.readOnly = true;
17
49
  return schema;
18
50
  }
19
- const properties = Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, addForwardRelations(value, resources, componentNames, sourceResource)]));
20
- Object.keys(schema.properties).forEach((key) => {
21
- const relation = getRelationMetadata(key, resources, sourceResource);
22
- if (relation != null) {
23
- const relationSchema = createSchemaReference(componentNames[relation.targetResource]);
24
- properties[relation.relationName] = relation.isMany ? { items: relationSchema, type: 'array' } : relationSchema;
51
+ function annotateInput(schema, node, defaults) {
52
+ for (const [key, property] of Object.entries(schema.properties ?? {})) {
53
+ const child = node.children[key];
54
+ for (const attribute of ['description', 'example', 'writeOnly'])
55
+ if (child[attribute] !== undefined)
56
+ property[attribute] = child[attribute];
57
+ if (defaults && child.default !== undefined)
58
+ property.default = child.default;
59
+ if (child.base === 'object')
60
+ annotateInput(child.many ? property.items : property, child, defaults);
25
61
  }
26
- });
27
- return { ...schema, properties };
28
- };
29
- const collectRelations = (schema, resources, sourceResource, relations = []) => {
30
- if (Array.isArray(schema.oneOf)) {
31
- schema.oneOf.forEach((nestedSchema) => {
32
- collectRelations(nestedSchema, resources, sourceResource, relations);
33
- });
34
- return relations;
35
- }
36
- if (schema.type === 'array') {
37
- collectRelations(schema.items ?? {}, resources, sourceResource, relations);
38
- return relations;
39
- }
40
- if (schema.type !== 'object' || !isObject(schema.properties)) {
41
- return relations;
62
+ return schema;
42
63
  }
43
- Object.entries(schema.properties).forEach(([key, value]) => {
44
- const relation = getRelationMetadata(key, resources, sourceResource);
45
- if (relation != null) {
46
- relations.push(relation);
64
+ function output(entity, node) {
65
+ if (node.relation) {
66
+ entity = node.relation;
67
+ node = entity.root;
47
68
  }
48
- collectRelations(value, resources, sourceResource, relations);
49
- });
50
- return relations;
51
- };
52
- const addReverseRelations = (schemas, rawSchemas, resources, componentNames) => {
53
- resources.forEach((sourceResource) => {
54
- collectRelations(rawSchemas[sourceResource], resources, sourceResource).forEach(({ reverseRelationName, targetResource }) => {
55
- const targetComponentName = componentNames[targetResource];
56
- const targetSchema = schemas[targetComponentName];
57
- if (targetSchema.type === 'object' && isObject(targetSchema.properties) && !Object.hasOwn(targetSchema.properties, reverseRelationName)) {
58
- schemas[targetComponentName] = {
59
- ...targetSchema,
60
- properties: {
61
- ...targetSchema.properties,
62
- [reverseRelationName]: { items: createSchemaReference(componentNames[sourceResource]), type: 'array' },
63
- },
64
- };
69
+ const name = nodeName(entity, node);
70
+ if (!reserve(name, node))
71
+ return ref(name);
72
+ const properties = {};
73
+ for (const [key, child] of Object.entries(node.children)) {
74
+ if (child.writeOnly)
75
+ continue;
76
+ if (child.relation || child.base === 'object') {
77
+ const value = child.many ? page(entity, child) : output(entity, child);
78
+ properties[key] = child.nullable || (child.relation && !child.many && !child.required) ? { anyOf: [value, { type: 'object', nullable: true, enum: [null] }] } : value;
65
79
  }
66
- });
67
- });
68
- };
69
- const createParameters = (maxPageSize) => ({
70
- ContentDirectory: { description: 'URI-encoded relative storage directory', in: 'header', name: FILE_HEADERS.directory.name, schema: { type: 'string' } },
71
- ContentName: { description: 'URI-encoded file name', in: 'header', name: FILE_HEADERS.name.name, required: true, schema: { type: 'string' } },
72
- ContentOverride: {
73
- description: 'Overwrite an existing file at the same path',
74
- in: 'header',
75
- name: FILE_HEADERS.override.name,
76
- schema: { default: 'false', enum: ['false', 'true'], type: 'string' },
77
- },
78
- Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
79
- FilePath: { description: 'Percent-encoded file path relative to the storage directory', in: 'path', name: 'path', required: true, schema: { type: 'string' } },
80
- Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
81
- Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
82
- PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum: maxPageSize, minimum: 1, type: 'integer' } },
83
- Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
84
- Where: { description: 'JSON-encoded filter for nested data', in: 'query', name: '_where', schema: { type: 'string' } },
85
- });
86
- const createJsonContent = (schema) => ({ content: { 'application/json': { schema } } });
87
- const createResponse = (description, schema) => ({ description, ...(schema != null && createJsonContent(schema)) });
88
- const createErrorResponse = (description) => createResponse(description, createSchemaReference('Error'));
89
- const createParameterReference = (name) => ({ $ref: `#/components/parameters/${name}` });
90
- const createRequestBody = (name) => ({ required: true, ...createJsonContent(createSchemaReference(name)) });
91
- const createFilePaths = () => ({
92
- [`${FILE_ROUTES.download}/{path}`]: {
93
- get: {
94
- operationId: 'downloadFile',
95
- parameters: [createParameterReference('FilePath')],
96
- responses: {
97
- 200: { content: { '*/*': { schema: { format: 'binary', type: 'string' } } }, description: 'File download' },
98
- 400: createErrorResponse('Invalid path'),
99
- 404: createErrorResponse('Not found'),
100
- },
101
- tags: ['files'],
102
- },
103
- },
104
- [`${FILE_ROUTES.metadata}/{path}`]: {
105
- get: {
106
- operationId: 'getFileMetadata',
107
- parameters: [createParameterReference('FilePath')],
108
- responses: {
109
- 200: createResponse('File metadata', createSchemaReference('FileMetadata')),
110
- 400: createErrorResponse('Invalid path'),
111
- 404: createErrorResponse('Not found'),
112
- },
113
- tags: ['files'],
114
- },
115
- },
116
- [FILE_ROUTES.storage]: {
117
- post: {
118
- operationId: 'uploadFile',
119
- parameters: ['ContentName', 'ContentDirectory', 'ContentOverride'].map(createParameterReference),
120
- requestBody: { content: { '*/*': { schema: { format: 'binary', type: 'string' } } }, required: true },
121
- responses: {
122
- 200: createResponse('Overwritten', createSchemaReference('FileMetadata')),
123
- 201: createResponse('Created', createSchemaReference('FileMetadata')),
124
- 400: createErrorResponse('Invalid request'),
125
- 409: createErrorResponse('Already exists'),
126
- 413: createErrorResponse('File is too large'),
127
- 415: createErrorResponse('Unsupported media type'),
128
- },
129
- tags: ['files'],
130
- },
131
- },
132
- [`${FILE_ROUTES.storage}/{path}`]: {
133
- delete: {
134
- operationId: 'deleteFile',
135
- parameters: [createParameterReference('FilePath')],
136
- responses: {
137
- 204: { description: 'Deleted' },
138
- 400: createErrorResponse('Invalid path'),
139
- 404: createErrorResponse('Not found'),
140
- },
141
- tags: ['files'],
142
- },
143
- get: {
144
- operationId: 'getFileContent',
145
- parameters: [createParameterReference('FilePath')],
146
- responses: {
147
- 200: { content: { '*/*': { schema: { format: 'binary', type: 'string' } } }, description: 'File contents' },
148
- 400: createErrorResponse('Invalid path'),
149
- 404: createErrorResponse('Not found'),
150
- },
151
- tags: ['files'],
152
- },
153
- patch: {
154
- operationId: 'updateFile',
155
- parameters: [createParameterReference('FilePath')],
156
- requestBody: createRequestBody('FileUpdate'),
157
- responses: {
158
- 200: createResponse('Updated', createSchemaReference('FileMetadata')),
159
- 400: createErrorResponse('Invalid request'),
160
- 404: createErrorResponse('Not found'),
161
- 409: createErrorResponse('Already exists'),
162
- 413: createErrorResponse('Request is too large'),
163
- 415: createErrorResponse('Unsupported media type'),
164
- },
165
- tags: ['files'],
166
- },
167
- },
168
- });
169
- const createResourceOperationIds = (resource) => {
170
- const resourceName = toPascalCase(resource);
171
- return {
172
- create: `post${resourceName}`,
173
- get: `get${resourceName}ById`,
174
- list: `get${resourceName}`,
175
- remove: `delete${resourceName}ById`,
176
- replace: `put${resourceName}ById`,
177
- update: `patch${resourceName}ById`,
178
- };
179
- };
180
- const createResourcePaths = (resource, componentName) => {
181
- const operationIds = createResourceOperationIds(resource);
182
- return {
183
- [`/${resource}`]: {
184
- get: {
185
- operationId: operationIds.list,
186
- parameters: ['Page', 'PerPage', 'Sort', 'Where', 'Embed'].map(createParameterReference),
187
- responses: { 200: createResponse('Successful response', createSchemaReference(`${componentName}Page`)), 400: createErrorResponse('Invalid query') },
188
- tags: [resource],
189
- },
190
- post: {
191
- operationId: operationIds.create,
192
- requestBody: createRequestBody(`${componentName}Create`),
193
- responses: { 201: createResponse('Created', createSchemaReference(componentName)), 400: createErrorResponse('Invalid request') },
194
- tags: [resource],
195
- },
196
- },
197
- [`/${resource}/{id}`]: {
198
- delete: {
199
- operationId: operationIds.remove,
200
- parameters: [createParameterReference('Id')],
201
- responses: { 200: createResponse('Deleted', createSchemaReference(componentName)), 404: createErrorResponse('Not found') },
202
- tags: [resource],
203
- },
204
- get: {
205
- operationId: operationIds.get,
206
- parameters: [createParameterReference('Id'), createParameterReference('Embed')],
207
- responses: {
208
- 200: createResponse('Successful response', createSchemaReference(componentName)),
209
- 400: createErrorResponse('Invalid query'),
210
- 404: createErrorResponse('Not found'),
211
- },
212
- tags: [resource],
213
- },
214
- patch: {
215
- operationId: operationIds.update,
216
- parameters: [createParameterReference('Id')],
217
- requestBody: createRequestBody(`${componentName}Update`),
218
- responses: {
219
- 200: createResponse('Updated', createSchemaReference(componentName)),
220
- 400: createErrorResponse('Invalid request'),
221
- 404: createErrorResponse('Not found'),
222
- },
223
- tags: [resource],
224
- },
225
- put: {
226
- operationId: operationIds.replace,
227
- parameters: [createParameterReference('Id')],
228
- requestBody: createRequestBody(`${componentName}Create`),
229
- responses: {
230
- 200: createResponse('Replaced', createSchemaReference(componentName)),
231
- 400: createErrorResponse('Invalid request'),
232
- 404: createErrorResponse('Not found'),
233
- },
234
- tags: [resource],
235
- },
236
- },
237
- };
238
- };
239
- const validateGeneratedNames = (resources, componentNames, files) => {
240
- const schemaOwners = new Map([
241
- ['Error', 'встроенная схема ошибки'],
242
- ...(files
243
- ? [
244
- ['FileMetadata', 'встроенная схема метаданных файла'],
245
- ['FileUpdate', 'встроенная схема изменения файла'],
246
- ]
247
- : []),
248
- ]);
249
- resources.forEach((resource) => {
250
- const componentName = componentNames[resource];
251
- if (componentName === '') {
252
- throw new Error(`Не удалось сформировать имя OpenAPI-схемы для ресурса «${resource}». Укажите $schema.${resource}.name`);
80
+ else
81
+ properties[key] = baseField(child);
253
82
  }
254
- [componentName, `${componentName}Create`, `${componentName}Update`, `${componentName}Page`].forEach((schemaName) => {
255
- const owner = schemaOwners.get(schemaName);
256
- if (owner != null) {
257
- throw new Error(`Имя OpenAPI-схемы «${schemaName}» используется ресурсами «${owner}» и «${resource}». Укажите уникальный $schema.${resource}.name`);
258
- }
259
- schemaOwners.set(schemaName, resource);
260
- });
261
- });
262
- };
263
- const validateOperationIds = (paths) => {
264
- const operationOwners = new Map();
265
- Object.entries(paths).forEach(([path, pathItem]) => {
266
- Object.entries(pathItem).forEach(([method, operation]) => {
267
- if (!isObject(operation) || typeof operation.operationId !== 'string') {
268
- return;
83
+ // scope may select any subset, so response properties are intentionally optional.
84
+ schemas[name] = { type: 'object', additionalProperties: false, properties };
85
+ return ref(name);
86
+ }
87
+ function page(entity, node) {
88
+ if (node.relation) {
89
+ entity = node.relation;
90
+ node = entity.root;
91
+ }
92
+ const name = `${nodeName(entity, node)}Page`;
93
+ if (reserve(name, node))
94
+ schemas[name] = { type: 'object', required: ['data', 'total'], properties: { data: { type: 'array', items: output(entity, node) }, total: { type: 'integer', minimum: 0 } } };
95
+ return ref(name);
96
+ }
97
+ function where(entity, node) {
98
+ if (node.relation) {
99
+ entity = node.relation;
100
+ node = entity.root;
101
+ }
102
+ const name = `${nodeName(entity, node)}Where`;
103
+ if (!reserve(name, node))
104
+ return ref(name);
105
+ const properties = { and: { type: 'array', items: ref(name) }, or: { type: 'array', minItems: 1, items: ref(name) }, not: ref(name) };
106
+ for (const [key, child] of Object.entries(node.children))
107
+ if (!child.writeOnly) {
108
+ if (Object.hasOwn(properties, key))
109
+ throw new Error(`Reserved filter field: ${entity.name}.${child.path}`);
110
+ properties[key] = !child.many && (child.relation || child.base === 'object') ? where(entity, child) : filter(entity, child);
269
111
  }
270
- const owner = operationOwners.get(operation.operationId);
271
- if (owner != null) {
272
- throw new Error(`operationId «${operation.operationId}» используется операциями «${owner}» и «${method.toUpperCase()} ${path}»`);
112
+ schemas[name] = { type: 'object', additionalProperties: false, properties };
113
+ return ref(name);
114
+ }
115
+ function filter(entity, node) {
116
+ const name = `${nodeName(entity, node)}Filter`;
117
+ if (!reserve(name, node))
118
+ return ref(name);
119
+ const properties = { not: ref(name) };
120
+ const object = node.relation || node.base === 'object';
121
+ const scalar = { type: node.base, nullable: true, ...(node.enum ? { enum: node.enum } : {}) };
122
+ if (node.many) {
123
+ const element = object ? where(entity, node) : filter(entity, { ...node, many: false, path: `${node.path}_element` });
124
+ for (const key of ['some', 'every', 'none'])
125
+ properties[key] = element;
126
+ if (!object) {
127
+ properties.contains = scalar;
128
+ properties.in = { type: 'array', items: scalar };
273
129
  }
274
- operationOwners.set(operation.operationId, `${method.toUpperCase()} ${path}`);
275
- });
276
- });
277
- };
278
- /** Builds an OpenAPI document without runtime server addresses. */
279
- export function buildOpenapiDocument(options) {
280
- const { database, files = false, maxPageSize = DEFAULT_MAX_PAGE_SIZE, schema: schemaConfig = {} } = options ?? {};
281
- if (typeof files !== 'boolean') {
282
- throw new Error('Ключ files должен содержать boolean');
130
+ }
131
+ else {
132
+ for (const key of ['eq', 'ne'])
133
+ properties[key] = scalar;
134
+ properties.in = { type: 'array', items: scalar };
135
+ if (node.base !== 'boolean')
136
+ for (const key of ['gt', 'gte', 'lt', 'lte'])
137
+ properties[key] = { type: node.base };
138
+ if (node.base === 'string')
139
+ for (const key of ['contains', 'startsWith', 'endsWith'])
140
+ properties[key] = { type: 'string' };
141
+ }
142
+ schemas[name] = { type: 'object', additionalProperties: false, properties };
143
+ return ref(name);
283
144
  }
284
- validateDatabase(database);
285
- if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
286
- throw new Error('Максимальный размер страницы должен быть положительным целым числом');
145
+ function order(entity, node) {
146
+ if (node.relation) {
147
+ entity = node.relation;
148
+ node = entity.root;
149
+ }
150
+ const name = `${nodeName(entity, node)}Order`;
151
+ if (reserve(name, node)) {
152
+ const paths = sortableFields(node);
153
+ schemas[name] = {
154
+ type: 'array',
155
+ ...(paths.length
156
+ ? {
157
+ items: {
158
+ type: 'object',
159
+ additionalProperties: false,
160
+ required: ['field', 'direction'],
161
+ properties: { field: { type: 'string', enum: paths }, direction: { type: 'string', enum: ['ASC', 'DESC'] } },
162
+ },
163
+ }
164
+ : { maxItems: 0, items: { type: 'object' } }),
165
+ };
166
+ }
167
+ return ref(name);
287
168
  }
288
- if (!isObject(schemaConfig)) {
289
- throw new Error('Схема базы данных должна содержать JSON-объект');
169
+ function options(entity, node) {
170
+ return { type: 'object', additionalProperties: false, properties: { where: where(entity, node), order: order(entity, node), pager: ref('Pager') } };
290
171
  }
291
- const resources = getResourceNames(database);
292
- const resourceConfigs = normalizeSchemaConfig(schemaConfig, resources);
293
- const componentNames = Object.fromEntries(resources.map((resource) => {
294
- const resourceConfig = resourceConfigs[resource];
295
- const componentName = resourceConfig.name ?? toPascalCase(singularize(resource));
296
- return [resource, componentName];
297
- }));
298
- validateGeneratedNames(resources, componentNames, files);
299
- const rawSchemas = Object.fromEntries(resources.map((resource) => {
300
- const resourceConfig = resourceConfigs[resource];
301
- const inferredSchema = database[resource].length === 0 ? { properties: { id: { type: 'string' } }, type: 'object' } : inferObjectSchema(database[resource]);
302
- const configuredSchema = mergeSchemaOverrides(inferredSchema, { properties: resourceConfig.properties });
303
- return [resource, applyConfiguredFields(ensureGeneratedIdSchema(configuredSchema), resource, resourceConfig)];
304
- }));
305
- const schemas = {
306
- Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
307
- ...(files && {
308
- FileMetadata: FILE_METADATA_SCHEMA,
309
- FileUpdate: FILE_UPDATE_SCHEMA,
310
- }),
311
- };
312
- resources.forEach((resource) => {
313
- const componentName = componentNames[resource];
314
- const rawSchema = rawSchemas[resource];
315
- schemas[componentName] = addForwardRelations(rawSchema, resources, componentNames, resource);
316
- schemas[`${componentName}Create`] = omitId(rawSchema, true);
317
- schemas[`${componentName}Update`] = omitId(rawSchema, false);
318
- schemas[`${componentName}Page`] = {
319
- properties: {
320
- data: { items: createSchemaReference(componentName), type: 'array' },
321
- total: { minimum: 0, type: 'integer' },
322
- },
323
- required: ['data', 'total'],
172
+ const paths = {};
173
+ const operations = new Set();
174
+ for (const entity of model.entities.filter((e) => e.api.includes('openapi'))) {
175
+ const name = entity.name;
176
+ const op = operationName(entity);
177
+ for (const mode of ['create', 'replace', 'update']) {
178
+ const key = `${name}${mode[0].toUpperCase() + mode.slice(1)}`;
179
+ reserve(key, entity.root);
180
+ schemas[key] = annotateInput(toOpenapi(objectSchema(entity.root, mode, true)), entity.root, mode !== 'update');
181
+ }
182
+ const nestedName = `${name}Nested`;
183
+ reserve(nestedName, entity.root);
184
+ const nestedProperties = {};
185
+ const collect = (owner, node, prefix, seen) => {
186
+ for (const [key, child] of Object.entries(childrenOf(node))) {
187
+ if (child.writeOnly || (!child.relation && child.base !== 'object'))
188
+ continue;
189
+ const path = prefix + key;
190
+ if (child.many)
191
+ nestedProperties[path] = options(owner, child);
192
+ const target = child.relation?.root ?? child;
193
+ if (!seen.has(target))
194
+ collect(child.relation ?? owner, target, `${path}.`, new Set([...seen, target]));
195
+ }
196
+ };
197
+ collect(entity, entity.root, '', new Set([entity.root]));
198
+ schemas[nestedName] = {
324
199
  type: 'object',
200
+ properties: nestedProperties,
201
+ additionalProperties: { type: 'object', additionalProperties: false, properties: { where: { type: 'object' }, order: { type: 'array', items: { type: 'object' } }, pager: ref('Pager') } },
202
+ description: 'Path -> list options. Recursive paths are validated against the model at runtime.',
325
203
  };
326
- });
327
- addReverseRelations(schemas, rawSchemas, resources, componentNames);
328
- const paths = Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource])), files ? createFilePaths() : {});
329
- validateOperationIds(paths);
330
- return {
331
- components: { parameters: createParameters(maxPageSize), schemas },
332
- info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
333
- openapi: '3.0.3',
334
- paths,
335
- tags: [...new Set([...resources, ...(files ? ['files'] : [])])].map((name) => ({ name })),
336
- };
204
+ const shape = [
205
+ { in: 'query', name: 'scope', schema: { type: 'string' }, description: 'Own fields and explicit relations, e.g. *,movies(id,title). * excludes relations and writeOnly fields.' },
206
+ { in: 'query', name: 'nested', ...json(ref(nestedName)) },
207
+ ];
208
+ const list = [...shape, ...Object.entries(options(entity, entity.root).properties ?? {}).map(([key, schema]) => ({ in: 'query', name: key, ...json(schema) }))];
209
+ const key = { in: 'path', name: entity.primary, required: true, schema: baseField({ ...entity.fields[entity.primary], generated: undefined }) };
210
+ const errors = { 400: response('Invalid request', ref('Error')), 404: response('Not found', ref('Error')), 409: response('Conflict', ref('Error')) };
211
+ const make = (operationId, parameters, schema, mode) => {
212
+ if (operations.has(operationId))
213
+ throw new Error(`OpenAPI operation collision: ${operationId}`);
214
+ operations.add(operationId);
215
+ return {
216
+ operationId,
217
+ tags: [entity.collection],
218
+ parameters,
219
+ ...(mode ? { requestBody: { required: true, ...json(ref(`${name}${mode[0].toUpperCase() + mode.slice(1)}`)) } } : {}),
220
+ responses: { [mode === 'create' ? 201 : 200]: response('Success', schema), ...errors },
221
+ };
222
+ };
223
+ paths[`/${entity.collection}`] = { get: make(`${op}List`, list, page(entity, entity.root)), post: make(`${op}Create`, shape, output(entity, entity.root), 'create') };
224
+ paths[`/${entity.collection}/{${entity.primary}}`] = {
225
+ get: make(op, [key, ...shape], output(entity, entity.root)),
226
+ put: make(`${op}Replace`, [key, ...shape], output(entity, entity.root), 'replace'),
227
+ patch: make(`${op}Update`, [key, ...shape], output(entity, entity.root), 'update'),
228
+ delete: make(`${op}Delete`, [key, ...shape], output(entity, entity.root)),
229
+ };
230
+ }
231
+ const parameters = {};
232
+ if (files) {
233
+ for (const [name, schema] of Object.entries({ FileMetadata: FILE_METADATA_SCHEMA, FileUpdate: FILE_UPDATE_SCHEMA })) {
234
+ if (schemas[name])
235
+ throw new Error(`OpenAPI schema collision: ${name}`);
236
+ schemas[name] = schema;
237
+ }
238
+ Object.assign(parameters, {
239
+ FilePath: { in: 'path', name: 'path', required: true, schema: { type: 'string' } },
240
+ ContentDirectory: { in: 'header', name: FILE_HEADERS.directory.name, schema: { type: 'string' } },
241
+ ContentName: { in: 'header', name: FILE_HEADERS.name.name, required: true, schema: { type: 'string' } },
242
+ ContentOverride: { in: 'header', name: FILE_HEADERS.override.name, schema: { type: 'string', enum: ['false', 'true'], default: 'false' } },
243
+ });
244
+ for (const [path, item] of Object.entries(createFilePaths())) {
245
+ if (paths[path])
246
+ throw new Error(`File path collision: ${path}`);
247
+ paths[path] = item;
248
+ }
249
+ }
250
+ return { openapi: '3.0.3', info, components: { schemas, parameters }, paths };
337
251
  }
338
252
  //# sourceMappingURL=document.js.map