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

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 (95) hide show
  1. package/README.md +313 -403
  2. package/README.ru.md +307 -397
  3. package/dist/index.d.ts +5 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/src/cli.js +95 -66
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/config.d.ts +18 -2
  9. package/dist/src/config.js +39 -10
  10. package/dist/src/config.js.map +1 -1
  11. package/dist/src/constants.d.ts +2 -1
  12. package/dist/src/constants.js +2 -1
  13. package/dist/src/constants.js.map +1 -1
  14. package/dist/src/database.d.ts +4 -3
  15. package/dist/src/database.js +32 -19
  16. package/dist/src/database.js.map +1 -1
  17. package/dist/src/engine.d.ts +47 -0
  18. package/dist/src/engine.js +371 -0
  19. package/dist/src/engine.js.map +1 -0
  20. package/dist/src/errors.d.ts +6 -0
  21. package/dist/src/errors.js +9 -0
  22. package/dist/src/errors.js.map +1 -0
  23. package/dist/src/features.d.ts +8 -0
  24. package/dist/src/features.js +18 -0
  25. package/dist/src/features.js.map +1 -0
  26. package/dist/src/files/disk-store.js +17 -6
  27. package/dist/src/files/disk-store.js.map +1 -1
  28. package/dist/src/files/openapi.d.ts +3 -0
  29. package/dist/src/files/openapi.js +86 -0
  30. package/dist/src/files/openapi.js.map +1 -0
  31. package/dist/src/graphql/preflight.d.ts +3 -0
  32. package/dist/src/graphql/preflight.js +28 -0
  33. package/dist/src/graphql/preflight.js.map +1 -0
  34. package/dist/src/graphql/routes.d.ts +4 -0
  35. package/dist/src/graphql/routes.js +28 -0
  36. package/dist/src/graphql/routes.js.map +1 -0
  37. package/dist/src/graphql.d.ts +4 -0
  38. package/dist/src/graphql.js +227 -0
  39. package/dist/src/graphql.js.map +1 -0
  40. package/dist/src/model.d.ts +65 -0
  41. package/dist/src/model.js +345 -0
  42. package/dist/src/model.js.map +1 -0
  43. package/dist/src/openapi/document.d.ts +7 -7
  44. package/dist/src/openapi/document.js +237 -320
  45. package/dist/src/openapi/document.js.map +1 -1
  46. package/dist/src/openapi/index.d.ts +3 -6
  47. package/dist/src/openapi/index.js +2 -3
  48. package/dist/src/openapi/index.js.map +1 -1
  49. package/dist/src/query/contract.d.ts +3 -0
  50. package/dist/src/query/contract.js +19 -0
  51. package/dist/src/query/contract.js.map +1 -0
  52. package/dist/src/query/filter.d.ts +3 -5
  53. package/dist/src/query/filter.js +76 -240
  54. package/dist/src/query/filter.js.map +1 -1
  55. package/dist/src/query/options.d.ts +18 -0
  56. package/dist/src/query/options.js +30 -0
  57. package/dist/src/query/options.js.map +1 -0
  58. package/dist/src/rest/options.d.ts +14 -0
  59. package/dist/src/rest/options.js +108 -0
  60. package/dist/src/rest/options.js.map +1 -0
  61. package/dist/src/rest/projection.d.ts +6 -0
  62. package/dist/src/rest/projection.js +37 -0
  63. package/dist/src/rest/projection.js.map +1 -0
  64. package/dist/src/rest/routes.d.ts +3 -0
  65. package/dist/src/rest/routes.js +48 -0
  66. package/dist/src/rest/routes.js.map +1 -0
  67. package/dist/src/schema.d.ts +17 -0
  68. package/dist/src/schema.js +23 -0
  69. package/dist/src/schema.js.map +1 -0
  70. package/dist/src/server.d.ts +3 -5
  71. package/dist/src/server.js +76 -162
  72. package/dist/src/server.js.map +1 -1
  73. package/dist/src/types.d.ts +1 -3
  74. package/dist/src/utils.d.ts +0 -1
  75. package/dist/src/utils.js +0 -1
  76. package/dist/src/utils.js.map +1 -1
  77. package/package.json +9 -4
  78. package/dist/src/openapi/config.d.ts +0 -11
  79. package/dist/src/openapi/config.js +0 -204
  80. package/dist/src/openapi/config.js.map +0 -1
  81. package/dist/src/openapi/inference.d.ts +0 -14
  82. package/dist/src/openapi/inference.js +0 -145
  83. package/dist/src/openapi/inference.js.map +0 -1
  84. package/dist/src/query/index.d.ts +0 -3
  85. package/dist/src/query/index.js +0 -4
  86. package/dist/src/query/index.js.map +0 -1
  87. package/dist/src/query/pagination.d.ts +0 -11
  88. package/dist/src/query/pagination.js +0 -28
  89. package/dist/src/query/pagination.js.map +0 -1
  90. package/dist/src/query/sort.d.ts +0 -1
  91. package/dist/src/query/sort.js +0 -54
  92. package/dist/src/query/sort.js.map +0 -1
  93. package/dist/src/relations.d.ts +0 -12
  94. package/dist/src/relations.js +0 -155
  95. package/dist/src/relations.js.map +0 -1
@@ -1,338 +1,255 @@
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 { VERSION } from '../constants.js';
2
+ import { FILE_HEADERS, FILE_METADATA_SCHEMA, FILE_UPDATE_SCHEMA } from '../files/contract.js';
3
+ import { createFilePaths } from '../files/openapi.js';
4
+ import { assertApi, nodeName, objectSchema, operationName, valueSchema } from '../model.js';
5
+ import { operatorsFor } from '../query/contract.js';
6
+ import { childrenOf, sortableFields } from '../query/options.js';
7
+ import { isObject } from '../utils.js';
8
+ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
9
+ const json = (schema) => ({ content: { 'application/json': { schema } } });
10
+ const response = (description, schema) => ({ description, ...json(schema) });
11
+ function toOpenapi(schema) {
12
+ if (Array.isArray(schema.anyOf) && schema.anyOf.some((v) => isObject(v) && v.type === 'null')) {
13
+ const nonNull = schema.anyOf.find((v) => isObject(v) && v.type !== 'null');
14
+ const converted = toOpenapi(nonNull);
15
+ return { ...converted, nullable: true, ...(converted.enum ? { enum: [...converted.enum.filter((value) => value !== null), null] } : {}) };
12
16
  }
13
- if (schema.type === 'array') {
14
- return { ...schema, items: addForwardRelations(schema.items ?? {}, resources, componentNames, sourceResource) };
17
+ const result = { ...schema };
18
+ if (isObject(schema.properties))
19
+ result.properties = Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k, toOpenapi(v)]));
20
+ if (isObject(schema.items))
21
+ result.items = toOpenapi(schema.items);
22
+ return result;
23
+ }
24
+ export function buildOpenapiDocument({ model, files = false, pageSize = 10, maxPageSize = 100, info = { title: 'Deep JSON Server API', version: VERSION }, }) {
25
+ assertApi(model, 'openapi');
26
+ const schemas = {
27
+ Error: { type: 'object', properties: { error: { type: 'string' } }, required: ['error'] },
28
+ Pager: {
29
+ type: 'object',
30
+ additionalProperties: false,
31
+ properties: { page: { type: 'integer', minimum: 1, default: 1 }, pageSize: { type: 'integer', minimum: 1, maximum: maxPageSize, default: pageSize } },
32
+ },
33
+ };
34
+ const owners = new Map();
35
+ function reserve(name, node) {
36
+ if (owners.get(name) === node)
37
+ return false;
38
+ if (schemas[name])
39
+ throw new Error(`OpenAPI schema name collision: ${name}`);
40
+ owners.set(name, node);
41
+ schemas[name] = {};
42
+ return true;
15
43
  }
16
- if (schema.type !== 'object' || !isObject(schema.properties)) {
44
+ function baseField(node) {
45
+ const schema = toOpenapi(valueSchema(node));
46
+ for (const key of ['description', 'example', 'default', 'readOnly', 'writeOnly'])
47
+ if (node[key] !== undefined)
48
+ schema[key] = node[key];
49
+ if (node.generated)
50
+ schema.readOnly = true;
17
51
  return schema;
18
52
  }
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;
53
+ function annotateInput(schema, node, defaults) {
54
+ for (const [key, property] of Object.entries(schema.properties ?? {})) {
55
+ const child = node.children[key];
56
+ for (const attribute of ['description', 'example', 'writeOnly'])
57
+ if (child[attribute] !== undefined)
58
+ property[attribute] = child[attribute];
59
+ if (defaults && child.default !== undefined)
60
+ property.default = child.default;
61
+ if (child.base === 'object')
62
+ annotateInput(child.many ? property.items : property, child, defaults);
25
63
  }
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;
64
+ return schema;
35
65
  }
36
- if (schema.type === 'array') {
37
- collectRelations(schema.items ?? {}, resources, sourceResource, relations);
38
- return relations;
66
+ function output(entity, node) {
67
+ if (node.relation) {
68
+ entity = node.relation;
69
+ node = entity.root;
70
+ }
71
+ const name = nodeName(entity, node);
72
+ if (!reserve(name, node))
73
+ return ref(name);
74
+ const properties = {};
75
+ for (const [key, child] of Object.entries(node.children)) {
76
+ if (child.writeOnly)
77
+ continue;
78
+ if (child.relation || child.base === 'object') {
79
+ const value = child.many ? page(entity, child) : output(entity, child);
80
+ properties[key] = child.nullable || (child.relation && !child.many && !child.required) ? { anyOf: [value, { type: 'object', nullable: true, enum: [null] }] } : value;
81
+ }
82
+ else
83
+ properties[key] = baseField(child);
84
+ }
85
+ // scope may select any subset, so response properties are intentionally optional.
86
+ schemas[name] = { type: 'object', additionalProperties: false, properties };
87
+ return ref(name);
39
88
  }
40
- if (schema.type !== 'object' || !isObject(schema.properties)) {
41
- return relations;
89
+ function page(entity, node) {
90
+ if (node.relation) {
91
+ entity = node.relation;
92
+ node = entity.root;
93
+ }
94
+ const name = `${nodeName(entity, node)}Page`;
95
+ if (reserve(name, node))
96
+ schemas[name] = { type: 'object', required: ['data', 'total'], properties: { data: { type: 'array', items: output(entity, node) }, total: { type: 'integer', minimum: 0 } } };
97
+ return ref(name);
42
98
  }
43
- Object.entries(schema.properties).forEach(([key, value]) => {
44
- const relation = getRelationMetadata(key, resources, sourceResource);
45
- if (relation != null) {
46
- relations.push(relation);
99
+ function where(entity, node) {
100
+ if (node.relation) {
101
+ entity = node.relation;
102
+ node = entity.root;
47
103
  }
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
- };
104
+ const name = `${nodeName(entity, node)}Where`;
105
+ if (!reserve(name, node))
106
+ return ref(name);
107
+ const properties = { and: { type: 'array', items: ref(name) }, or: { type: 'array', minItems: 1, items: ref(name) }, not: ref(name) };
108
+ for (const [key, child] of Object.entries(node.children))
109
+ if (!child.writeOnly) {
110
+ if (Object.hasOwn(properties, key))
111
+ throw new Error(`Reserved filter field: ${entity.name}.${child.path}`);
112
+ properties[key] = !child.many && (child.relation || child.base === 'object') ? where(entity, child) : filter(entity, child);
65
113
  }
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`);
114
+ schemas[name] = { type: 'object', additionalProperties: false, properties };
115
+ return ref(name);
116
+ }
117
+ function filter(entity, node) {
118
+ const name = `${nodeName(entity, node)}Filter`;
119
+ if (!reserve(name, node))
120
+ return ref(name);
121
+ const properties = {};
122
+ const element = node.many ? (node.relation || node.base === 'object' ? where(entity, node) : filter(entity, { ...node, many: false, path: `${node.path}_element` })) : undefined;
123
+ for (const [key, operand] of Object.entries(operatorsFor(node))) {
124
+ const nullable = ['eq', 'ne', 'in'].includes(key);
125
+ const scalar = {
126
+ type: node.base,
127
+ ...(nullable ? { nullable: true } : {}),
128
+ ...(node.enum ? { enum: [...node.enum, ...(nullable && !node.enum.includes(null) ? [null] : [])] } : {}),
129
+ };
130
+ if (operand === 'condition')
131
+ properties[key] = ref(name);
132
+ else if (operand === 'element')
133
+ properties[key] = element;
134
+ else
135
+ properties[key] =
136
+ operand === 'values' ? { type: 'array', items: scalar } : operand === 'text' ? { type: 'string' } : operand === 'comparison' ? { type: node.base } : scalar;
253
137
  }
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;
269
- }
270
- const owner = operationOwners.get(operation.operationId);
271
- if (owner != null) {
272
- throw new Error(`operationId «${operation.operationId}» используется операциями «${owner}» и «${method.toUpperCase()} ${path}»`);
273
- }
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');
138
+ schemas[name] = { type: 'object', additionalProperties: false, properties };
139
+ return ref(name);
283
140
  }
284
- validateDatabase(database);
285
- if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
286
- throw new Error('Максимальный размер страницы должен быть положительным целым числом');
141
+ function order(entity, node) {
142
+ if (node.relation) {
143
+ entity = node.relation;
144
+ node = entity.root;
145
+ }
146
+ const name = `${nodeName(entity, node)}Order`;
147
+ if (reserve(name, node)) {
148
+ const paths = sortableFields(node);
149
+ schemas[name] = {
150
+ type: 'array',
151
+ ...(paths.length
152
+ ? {
153
+ items: {
154
+ type: 'object',
155
+ additionalProperties: false,
156
+ required: ['field', 'direction'],
157
+ properties: { field: { type: 'string', enum: paths }, direction: { type: 'string', enum: ['ASC', 'DESC'] } },
158
+ },
159
+ }
160
+ : { maxItems: 0, items: { type: 'object' } }),
161
+ };
162
+ }
163
+ return ref(name);
287
164
  }
288
- if (!isObject(schemaConfig)) {
289
- throw new Error('Схема базы данных должна содержать JSON-объект');
165
+ function options(entity, node) {
166
+ return { type: 'object', additionalProperties: false, properties: { where: where(entity, node), order: order(entity, node), pager: ref('Pager') } };
290
167
  }
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'],
168
+ const paths = {};
169
+ const operations = new Set();
170
+ for (const entity of model.entities.filter((e) => e.api.includes('openapi'))) {
171
+ const name = entity.name;
172
+ const op = operationName(entity);
173
+ for (const mode of ['create', 'replace', 'update']) {
174
+ const key = `${name}${mode[0].toUpperCase() + mode.slice(1)}`;
175
+ reserve(key, entity.root);
176
+ schemas[key] = annotateInput(toOpenapi(objectSchema(entity.root, mode, true)), entity.root, mode !== 'update');
177
+ }
178
+ const nestedName = `${name}Nested`;
179
+ reserve(nestedName, entity.root);
180
+ const nestedProperties = {};
181
+ const collect = (owner, node, prefix, seen) => {
182
+ for (const [key, child] of Object.entries(childrenOf(node))) {
183
+ if (child.writeOnly || (!child.relation && child.base !== 'object'))
184
+ continue;
185
+ const path = prefix + key;
186
+ if (child.many)
187
+ nestedProperties[path] = options(owner, child);
188
+ const target = child.relation?.root ?? child;
189
+ if (!seen.has(target))
190
+ collect(child.relation ?? owner, target, `${path}.`, new Set([...seen, target]));
191
+ }
192
+ };
193
+ collect(entity, entity.root, '', new Set([entity.root]));
194
+ schemas[nestedName] = {
324
195
  type: 'object',
196
+ properties: nestedProperties,
197
+ additionalProperties: { type: 'object', additionalProperties: false, properties: { where: { type: 'object' }, order: { type: 'array', items: { type: 'object' } }, pager: ref('Pager') } },
198
+ description: 'Path -> list options. Recursive paths are validated against the model at runtime.',
325
199
  };
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
- };
200
+ const shape = [
201
+ { in: 'query', name: 'scope', schema: { type: 'string' }, description: 'Own fields and explicit relations, e.g. *,movies(id,title). * excludes relations and writeOnly fields.' },
202
+ { in: 'query', name: 'nested', ...json(ref(nestedName)) },
203
+ ];
204
+ const list = [...shape, ...Object.entries(options(entity, entity.root).properties ?? {}).map(([key, schema]) => ({ in: 'query', name: key, ...json(schema) }))];
205
+ const key = { in: 'path', name: entity.primary, required: true, schema: baseField({ ...entity.fields[entity.primary], generated: undefined }) };
206
+ const errors = { 400: response('Invalid request', ref('Error')), 404: response('Not found', ref('Error')), 409: response('Conflict', ref('Error')) };
207
+ const make = (operationId, parameters, schema, mode) => {
208
+ if (operations.has(operationId))
209
+ throw new Error(`OpenAPI operation collision: ${operationId}`);
210
+ operations.add(operationId);
211
+ return {
212
+ operationId,
213
+ tags: [entity.collection],
214
+ parameters,
215
+ ...(mode ? { requestBody: { required: true, ...json(ref(`${name}${mode[0].toUpperCase() + mode.slice(1)}`)) } } : {}),
216
+ responses: { [mode === 'create' ? 201 : 200]: response('Success', schema), ...errors },
217
+ };
218
+ };
219
+ paths[`/${entity.collection}`] = { get: make(`${op}List`, list, page(entity, entity.root)), post: make(`${op}Create`, shape, output(entity, entity.root), 'create') };
220
+ paths[`/${entity.collection}/{${entity.primary}}`] = {
221
+ get: make(op, [key, ...shape], output(entity, entity.root)),
222
+ put: make(`${op}Replace`, [key, ...shape], output(entity, entity.root), 'replace'),
223
+ patch: make(`${op}Update`, [key, ...shape], output(entity, entity.root), 'update'),
224
+ delete: make(`${op}Delete`, [key, ...shape], output(entity, entity.root)),
225
+ };
226
+ }
227
+ const parameters = {};
228
+ if (files) {
229
+ for (const [name, schema] of Object.entries({ FileMetadata: FILE_METADATA_SCHEMA, FileUpdate: FILE_UPDATE_SCHEMA })) {
230
+ if (schemas[name])
231
+ throw new Error(`OpenAPI schema collision: ${name}`);
232
+ schemas[name] = schema;
233
+ }
234
+ Object.assign(parameters, {
235
+ FilePath: { in: 'path', name: 'path', required: true, schema: { type: 'string' } },
236
+ ContentDirectory: { in: 'header', name: FILE_HEADERS.directory.name, schema: { type: 'string' } },
237
+ ContentName: { in: 'header', name: FILE_HEADERS.name.name, required: true, schema: { type: 'string' } },
238
+ ContentOverride: { in: 'header', name: FILE_HEADERS.override.name, schema: { type: 'string', enum: ['false', 'true'], default: 'false' } },
239
+ });
240
+ for (const [path, item] of Object.entries(createFilePaths())) {
241
+ if (paths[path])
242
+ throw new Error(`File path collision: ${path}`);
243
+ for (const operation of Object.values(item)) {
244
+ if (isObject(operation) && typeof operation.operationId === 'string') {
245
+ if (operations.has(operation.operationId))
246
+ throw new Error(`OpenAPI operation collision: ${operation.operationId}`);
247
+ operations.add(operation.operationId);
248
+ }
249
+ }
250
+ paths[path] = item;
251
+ }
252
+ }
253
+ return { openapi: '3.0.3', info, components: { schemas, parameters }, paths };
337
254
  }
338
255
  //# sourceMappingURL=document.js.map