@kollors/deep-json-server 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ Add a script to `package.json`:
19
19
  ```json
20
20
  {
21
21
  "scripts": {
22
- "mock": "deep-json-server mock/database.json --port 4001",
22
+ "mock": "deep-json-server mock/database.json --schema mock/database-schema.json --port 4001",
23
23
  "openapi": "deep-json-server mock/database.json --generate mock/database-schema.json mock/openapi-schema.yaml"
24
24
  }
25
25
  }
@@ -106,7 +106,9 @@ PATCH /movies/:id
106
106
  DELETE /movies/:id
107
107
  ```
108
108
 
109
- `POST` generates a string ID, while `PUT` and `PATCH` preserve the stored ID type. All write operations — `POST`, `PUT`, `PATCH` and `DELETE` — persist their changes in the JSON file. The database file must exist before startup and contain a JSON object whose every top-level property is an array of resource records.
109
+ `POST` generates a string ID, while `PUT` and `PATCH` preserve the stored ID type. All write operations — `POST`, `PUT`, `PATCH` and `DELETE` — persist their changes in the JSON file.
110
+
111
+ The database file must exist before startup. Resource names may contain Latin letters, numbers, `_` and `-`, and must start with a letter. Every resource is an array of JSON objects. Every record must have a non-empty string or finite numeric `id`; IDs must be unique within a resource when compared as strings, so `1` and `"1"` cannot coexist.
110
112
 
111
113
  ## Pagination and sorting
112
114
 
@@ -128,9 +130,9 @@ A GET collection always returns a page object. `_page` defaults to `1`, and `_pe
128
130
  }
129
131
  ```
130
132
 
131
- Both pagination parameters must be positive integers. Invalid values return `400` instead of being silently corrected.
133
+ Both pagination parameters must be positive integers. `_perPage` cannot exceed `1000` by default; use the programmatic `maxPageSize` option to change that limit. Invalid values return `400` instead of being silently corrected. A page beyond the last page returns an empty `data` array and points `prev` to the last available page instead of silently clamping the request.
132
134
 
133
- Prefix a sort field with `-` for descending order.
135
+ Prefix a sort field with `-` for descending order. Unknown or unsafe sort fields return `400`.
134
136
 
135
137
  ## Filters
136
138
 
@@ -185,6 +187,8 @@ GET /movies/1?_embed=actors.user.country
185
187
  GET /genres/2?_embed=parents.parents
186
188
  ```
187
189
 
190
+ Unknown or malformed `_embed` paths return `400`.
191
+
188
192
  Reverse relationships work as well:
189
193
 
190
194
  ```http
@@ -273,22 +277,24 @@ Use `name` when a resource needs an explicit schema name instead of the automati
273
277
  }
274
278
  ```
275
279
 
276
- The generated document describes CRUD endpoints, pagination, sorting, deep filters, `_embed`, and response relations inferred from `...Id` and `...Ids` fields. It can be used as input for tools such as RTK Query OpenAPI Codegen. OpenAPI is generated only when `--generate` is passed; normal server startup does not rewrite the file.
280
+ The generated document describes CRUD endpoints, pagination, sorting, deep filters, `_embed`, and both direct and reverse response relations inferred from `...Id` and `...Ids` fields. A numeric database ID is described as `integer | string`, because a later `POST` creates a string ID in the same resource. The document can be used as input for tools such as RTK Query OpenAPI Codegen. OpenAPI is generated only when `--generate` is passed; normal server startup does not rewrite the file.
281
+
282
+ During normal startup, request bodies are validated against the inferred resource schemas. Pass `--schema mock/database-schema.json` to apply the same explicit `required`, `formats` and `properties` constraints at runtime. Invalid `POST`, `PUT` and `PATCH` bodies return `400`.
277
283
 
278
284
  ## Programmatic API
279
285
 
280
286
  ```js
281
287
  import { createServer, generateOpenApi, startServer } from '@kollors/deep-json-server';
282
288
 
283
- const server = await createServer({ databasePath: 'mock/database.json', logger: false });
289
+ const server = await createServer({ databasePath: 'mock/database.json', logger: false, maxPageSize: 1000, schemaPath: 'mock/database-schema.json' });
284
290
 
285
291
  const response = await server.inject({ method: 'GET', url: '/movies' });
286
292
 
287
293
  await server.close();
288
294
 
289
- await startServer({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001 });
295
+ await startServer({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001, schemaPath: 'mock/database-schema.json' });
290
296
 
291
297
  await generateOpenApi({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001, schemaPath: 'mock/database-schema.json', outputPath: 'mock/openapi-schema.yaml' });
292
298
  ```
293
299
 
294
- `createServer()` is useful for tests because it returns a Fastify instance without opening a network port.
300
+ `createServer()` is useful for tests because it returns a Fastify instance without opening a network port. The package includes generated TypeScript declarations for all exported functions.
package/README.ru.md CHANGED
@@ -19,7 +19,7 @@ npm install --save-dev @kollors/deep-json-server
19
19
  ```json
20
20
  {
21
21
  "scripts": {
22
- "mock": "deep-json-server mock/database.json --port 4001",
22
+ "mock": "deep-json-server mock/database.json --schema mock/database-schema.json --port 4001",
23
23
  "openapi": "deep-json-server mock/database.json --generate mock/database-schema.json mock/openapi-schema.yaml"
24
24
  }
25
25
  }
@@ -106,7 +106,9 @@ PATCH /movies/:id
106
106
  DELETE /movies/:id
107
107
  ```
108
108
 
109
- `POST` генерирует строковый ID, а `PUT` и `PATCH` сохраняют исходный тип ID. Все операции записи — `POST`, `PUT`, `PATCH` и `DELETE` — сохраняют изменения в JSON-файле. Файл базы должен существовать до запуска и содержать JSON-объект, каждое свойство верхнего уровня которого является массивом записей отдельного ресурса.
109
+ `POST` генерирует строковый ID, а `PUT` и `PATCH` сохраняют исходный тип ID. Все операции записи — `POST`, `PUT`, `PATCH` и `DELETE` — сохраняют изменения в JSON-файле.
110
+
111
+ Файл базы должен существовать до запуска. Имена ресурсов могут содержать латинские буквы, цифры, `_` и `-` и должны начинаться с буквы. Каждый ресурс является массивом JSON-объектов. У каждой записи должен быть непустой строковый или конечный числовой `id`; ID должны быть уникальны внутри ресурса при сравнении как строки, поэтому `1` и `"1"` не могут существовать одновременно.
110
112
 
111
113
  ## Пагинация и сортировка
112
114
 
@@ -128,9 +130,9 @@ GET-запрос к коллекции всегда возвращает объ
128
130
  }
129
131
  ```
130
132
 
131
- Оба параметра пагинации должны быть положительными целыми числами. При некорректном значении сервер возвращает `400`, а не исправляет его автоматически.
133
+ Оба параметра пагинации должны быть положительными целыми числами. По умолчанию `_perPage` не может превышать `1000`; лимит можно изменить программным параметром `maxPageSize`. При некорректном значении сервер возвращает `400`, а не исправляет его автоматически. Страница после последней возвращает пустой массив `data`, а `prev` указывает на последнюю доступную страницу.
132
134
 
133
- Префикс `-` перед полем включает сортировку по убыванию.
135
+ Префикс `-` перед полем включает сортировку по убыванию. Неизвестные и небезопасные поля сортировки возвращают `400`.
134
136
 
135
137
  ## Фильтры
136
138
 
@@ -185,6 +187,8 @@ GET /movies/1?_embed=actors.user.country
185
187
  GET /genres/2?_embed=parents.parents
186
188
  ```
187
189
 
190
+ Неизвестные и некорректные пути `_embed` возвращают `400`.
191
+
188
192
  Поддерживаются и обратные связи:
189
193
 
190
194
  ```http
@@ -273,22 +277,24 @@ deep-json-server mock/database.json --generate mock/database-schema.json mock/op
273
277
  }
274
278
  ```
275
279
 
276
- В сгенерированном документе описаны CRUD, пагинация, сортировка, глубокие фильтры, `_embed` и связи в ответах, определённые по полям `...Id` и `...Ids`. Файл можно передать, например, в RTK Query OpenAPI Codegen. OpenAPI создаётся только с параметром `--generate`; обычный запуск сервера файл не перезаписывает.
280
+ В сгенерированном документе описаны CRUD, пагинация, сортировка, глубокие фильтры, `_embed`, а также прямые и обратные связи в ответах, определённые по полям `...Id` и `...Ids`. Числовой ID базы описывается как `integer | string`, поскольку последующий `POST` создаст строковый ID в том же ресурсе. Файл можно передать, например, в RTK Query OpenAPI Codegen. OpenAPI создаётся только с параметром `--generate`; обычный запуск сервера файл не перезаписывает.
281
+
282
+ При обычном запуске тела запросов проверяются по автоматически выведенным схемам ресурсов. Передайте `--schema mock/database-schema.json`, чтобы в runtime применялись те же явные ограничения `required`, `formats` и `properties`. Некорректные тела `POST`, `PUT` и `PATCH` возвращают `400`.
277
283
 
278
284
  ## Программный API
279
285
 
280
286
  ```js
281
287
  import { createServer, generateOpenApi, startServer } from '@kollors/deep-json-server';
282
288
 
283
- const server = await createServer({ databasePath: 'mock/database.json', logger: false });
289
+ const server = await createServer({ databasePath: 'mock/database.json', logger: false, maxPageSize: 1000, schemaPath: 'mock/database-schema.json' });
284
290
 
285
291
  const response = await server.inject({ method: 'GET', url: '/movies' });
286
292
 
287
293
  await server.close();
288
294
 
289
- await startServer({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001 });
295
+ await startServer({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001, schemaPath: 'mock/database-schema.json' });
290
296
 
291
297
  await generateOpenApi({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001, schemaPath: 'mock/database-schema.json', outputPath: 'mock/openapi-schema.yaml' });
292
298
  ```
293
299
 
294
- `createServer()` удобен для тестов: он возвращает экземпляр Fastify, не открывая сетевой порт.
300
+ `createServer()` удобен для тестов: он возвращает экземпляр Fastify, не открывая сетевой порт. Пакет содержит сгенерированные TypeScript-декларации для всех экспортируемых функций.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from 'node:process';
4
+ import { runCli } from '../src/cli.js';
5
+
6
+ try {
7
+ await runCli();
8
+ } catch (error) {
9
+ process.stderr.write(`${error.message}\n`);
10
+ process.exitCode = 1;
11
+ }
package/index.js CHANGED
@@ -1,12 +1,2 @@
1
- #!/usr/bin/env node
2
-
3
- import process from 'node:process';
4
- import { runCli } from './src/cli.js';
5
- import { isMainModule } from './src/utils.js';
6
-
7
- export { createOpenApiDocument, generateOpenApi } from './src/openapi.js';
1
+ export { createOpenApiDocument, generateOpenApi } from './src/openapi/index.js';
8
2
  export { createServer, startServer } from './src/server.js';
9
-
10
- if (isMainModule(process.argv[1], import.meta.url)) {
11
- await runCli();
12
- }
package/package.json CHANGED
@@ -1,24 +1,37 @@
1
1
  {
2
2
  "name": "@kollors/deep-json-server",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "JSON mock server with deep filters and recursive relationship embedding",
5
5
  "type": "module",
6
+ "types": "./types/index.d.ts",
6
7
  "bin": {
7
- "deep-json-server": "index.js"
8
+ "deep-json-server": "bin/deep-json-server.js"
8
9
  },
9
10
  "exports": {
10
- ".": "./index.js"
11
+ ".": {
12
+ "types": "./types/index.d.ts",
13
+ "import": "./index.js"
14
+ }
11
15
  },
12
16
  "files": [
17
+ "bin",
13
18
  "index.js",
14
19
  "src",
20
+ "types",
15
21
  "LICENSE",
16
22
  "README.md",
17
23
  "README.ru.md"
18
24
  ],
19
25
  "scripts": {
20
- "check": "node --check index.js && node --check src/*.js",
21
- "test": "node --test"
26
+ "check": "node --check index.js && node --check bin/deep-json-server.js && node --check src/*.js && node --check src/openapi/*.js && node --check src/query/*.js",
27
+ "lint": "biome check .",
28
+ "lint:fix": "biome check --write .",
29
+ "prepack": "npm run types",
30
+ "prepublishOnly": "npm run verify",
31
+ "test": "node --test",
32
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=90 --test-coverage-branches=80 --test-coverage-functions=90",
33
+ "types": "tsc -p tsconfig.types.json",
34
+ "verify": "npm run check && npm run lint && npm run test:coverage && npm run types"
22
35
  },
23
36
  "engines": {
24
37
  "node": ">=20"
@@ -49,5 +62,9 @@
49
62
  "license": "MIT",
50
63
  "publishConfig": {
51
64
  "access": "public"
65
+ },
66
+ "devDependencies": {
67
+ "@biomejs/biome": "^2.5.11",
68
+ "typescript": "^7.0.2"
52
69
  }
53
70
  }
package/src/cli.js CHANGED
@@ -1,27 +1,29 @@
1
1
  import process from 'node:process';
2
- import { generateOpenApi } from './openapi.js';
2
+ import { DEFAULT_HOST, DEFAULT_PORT } from './constants.js';
3
+ import { generateOpenApi } from './openapi/index.js';
3
4
  import { startServer } from './server.js';
4
5
 
5
- const HELP = `Deep JSON Server
6
+ const HELP_TEXT = `Deep JSON Server
6
7
 
7
8
  Использование:
8
- deep-json-server <database.json> [--host <host>] [--port <port>]
9
+ deep-json-server <database.json> [--schema <database-schema.json>] [--host <host>] [--port <port>]
9
10
  deep-json-server <database.json> --generate <database-schema.json> <openapi-schema.yaml> [--host <host>] [--port <port>]
10
11
 
11
12
  Параметры:
12
13
  --generate Сгенерировать OpenAPI и завершить работу
13
14
  --host, -h Адрес сервера (по умолчанию 127.0.0.1)
14
15
  --port, -p Порт сервера (по умолчанию 4001)
16
+ --schema Проверять запросы записи по указанной схеме
15
17
  --help Показать справку`;
16
18
 
17
- const parseServerOptions = (args) => {
19
+ const parseOptions = (args, allowedOptions) => {
18
20
  const options = {};
19
21
 
20
- for (let index = 1; index < args.length; index += 2) {
22
+ for (let index = 0; index < args.length; index += 2) {
21
23
  const option = args[index];
22
24
  const value = args[index + 1];
23
25
 
24
- if (!['--host', '-h', '--port', '-p'].includes(option)) {
26
+ if (!allowedOptions.includes(option)) {
25
27
  throw new Error(`Неизвестный параметр: ${option}`);
26
28
  }
27
29
 
@@ -29,7 +31,9 @@ const parseServerOptions = (args) => {
29
31
  throw new Error(`Не указано значение параметра ${option}`);
30
32
  }
31
33
 
32
- options[['--host', '-h'].includes(option) ? 'host' : 'port'] = value;
34
+ const optionName = ['--host', '-h'].includes(option) ? 'host' : ['--port', '-p'].includes(option) ? 'port' : 'schemaPath';
35
+
36
+ options[optionName] = value;
33
37
  }
34
38
 
35
39
  return options;
@@ -37,7 +41,7 @@ const parseServerOptions = (args) => {
37
41
 
38
42
  export async function runCli(args = process.argv.slice(2)) {
39
43
  if (args.includes('--help')) {
40
- process.stdout.write(`${HELP}\n`);
44
+ process.stdout.write(`${HELP_TEXT}\n`);
41
45
  return;
42
46
  }
43
47
 
@@ -57,18 +61,18 @@ export async function runCli(args = process.argv.slice(2)) {
57
61
  throw new Error('Используйте: deep-json-server <database.json> --generate <database-schema.json> <openapi-schema.yaml> [--host <host>] [--port <port>]');
58
62
  }
59
63
 
60
- const options = parseServerOptions([databasePath, ...args.slice(4)]);
61
- const host = options.host ?? process.env.HOST ?? '127.0.0.1';
62
- const port = Number(options.port ?? process.env.PORT ?? 4001);
64
+ const options = parseOptions(args.slice(4), ['--host', '-h', '--port', '-p']);
65
+ const host = options.host ?? process.env.HOST ?? DEFAULT_HOST;
66
+ const port = Number(options.port ?? process.env.PORT ?? DEFAULT_PORT);
63
67
 
64
68
  await generateOpenApi({ databasePath, host, outputPath, port, schemaPath });
65
69
  process.stdout.write(`OpenAPI-схема сохранена в ${outputPath}\n`);
66
70
  return;
67
71
  }
68
72
 
69
- const options = parseServerOptions(args);
70
- const host = options.host ?? process.env.HOST ?? '127.0.0.1';
71
- const port = Number(options.port ?? process.env.PORT ?? 4001);
73
+ const options = parseOptions(args.slice(1), ['--host', '-h', '--port', '-p', '--schema']);
74
+ const host = options.host ?? process.env.HOST ?? DEFAULT_HOST;
75
+ const port = Number(options.port ?? process.env.PORT ?? DEFAULT_PORT);
72
76
 
73
- await startServer({ databasePath, host, port });
77
+ await startServer({ databasePath, host, port, schemaPath: options.schemaPath });
74
78
  }
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_HOST = '127.0.0.1';
2
+ export const DEFAULT_PAGE_SIZE = 10;
3
+ export const DEFAULT_PORT = 4001;
4
+ export const MAX_PAGE_SIZE = 1000;
@@ -0,0 +1,128 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { JSONFilePreset } from 'lowdb/node';
5
+ import { createHttpError, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
6
+
7
+ const RESOURCE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
8
+
9
+ export const validateDatabase = (data) => {
10
+ if (!isObject(data)) {
11
+ throw new Error('База данных должна содержать JSON-объект');
12
+ }
13
+
14
+ Object.entries(data).forEach(([resource, records]) => {
15
+ if (!RESOURCE_NAME_PATTERN.test(resource) || !isSafeKey(resource)) {
16
+ throw new Error(`Недопустимое имя ресурса «${resource}»`);
17
+ }
18
+
19
+ if (!Array.isArray(records)) {
20
+ throw new Error(`Ресурс «${resource}» должен содержать JSON-массив`);
21
+ }
22
+
23
+ const ids = new Set();
24
+
25
+ records.forEach((record, index) => {
26
+ if (!isObject(record)) {
27
+ throw new Error(`Запись ${index} ресурса «${resource}» должна содержать JSON-объект`);
28
+ }
29
+
30
+ if (typeof record.id !== 'string' && !(typeof record.id === 'number' && Number.isFinite(record.id))) {
31
+ throw new Error(`Запись ${index} ресурса «${resource}» должна содержать строковый или числовой id`);
32
+ }
33
+
34
+ if (String(record.id) === '') {
35
+ throw new Error(`Запись ${index} ресурса «${resource}» должна содержать непустой id`);
36
+ }
37
+
38
+ const id = String(record.id);
39
+
40
+ if (ids.has(id)) {
41
+ throw new Error(`Ресурс «${resource}» содержит повторяющийся id «${id}»`);
42
+ }
43
+
44
+ ids.add(id);
45
+ });
46
+ });
47
+
48
+ return data;
49
+ };
50
+
51
+ export const readJsonObject = async (path, label) => {
52
+ let source;
53
+
54
+ try {
55
+ source = await readFile(resolve(path), 'utf8');
56
+ } catch (error) {
57
+ if (error?.code === 'ENOENT') {
58
+ throw new Error(`${label} не найден: ${resolve(path)}`);
59
+ }
60
+
61
+ throw error;
62
+ }
63
+
64
+ const value = JSON.parse(source);
65
+
66
+ if (!isObject(value)) {
67
+ throw new Error(`${label} должен содержать JSON-объект`);
68
+ }
69
+
70
+ return value;
71
+ };
72
+
73
+ export const readDatabaseFile = async (databasePath) => validateDatabase(await readJsonObject(databasePath, 'Файл базы данных'));
74
+
75
+ /** @returns {Promise<any>} Internal LowDB-backed store. */
76
+ export const createDatabaseStore = async (databasePath) => {
77
+ const resolvedDatabasePath = resolveDatabasePath(databasePath);
78
+ const initialData = await readDatabaseFile(resolvedDatabasePath);
79
+ const database = await JSONFilePreset(resolvedDatabasePath, initialData);
80
+ let writeQueue = Promise.resolve();
81
+
82
+ const read = async () => {
83
+ database.data = await readDatabaseFile(resolvedDatabasePath);
84
+
85
+ return database.data;
86
+ };
87
+
88
+ const update = (operation) => {
89
+ const pendingOperation = writeQueue.then(async () => {
90
+ await read();
91
+
92
+ const result = operation(database);
93
+
94
+ validateDatabase(database.data);
95
+ await database.write();
96
+
97
+ return result;
98
+ });
99
+
100
+ writeQueue = pendingOperation.catch(() => undefined);
101
+
102
+ return pendingOperation;
103
+ };
104
+
105
+ return { database, path: resolvedDatabasePath, read, update };
106
+ };
107
+
108
+ export const getCollection = (database, resource) => {
109
+ const collection = isSafeKey(resource) ? database.data[resource] : undefined;
110
+
111
+ if (!Array.isArray(collection)) {
112
+ throw createHttpError(404, 'Ресурс не найден');
113
+ }
114
+
115
+ return collection;
116
+ };
117
+
118
+ export const findItem = (collection, id) => collection.find((item) => String(item.id) === String(id));
119
+
120
+ export const createId = (collection) => {
121
+ let id;
122
+
123
+ do {
124
+ id = randomBytes(8).toString('base64url');
125
+ } while (findItem(collection, id) != null);
126
+
127
+ return id;
128
+ };
@@ -0,0 +1,110 @@
1
+ import { readJsonObject } from '../database.js';
2
+ import { isObject } from '../utils.js';
3
+ import { applyRequiredFields, getSchemasAtPath, updateSchemasAtPath } from './inference.js';
4
+
5
+ const validateSchemaOverride = (schema, path) => {
6
+ if (!isObject(schema)) {
7
+ throw new Error(`OpenAPI-схема свойства «${path}» должна содержать JSON-объект`);
8
+ }
9
+
10
+ if (schema.properties != null) {
11
+ if (!isObject(schema.properties)) {
12
+ throw new Error(`properties свойства «${path}» должен содержать JSON-объект`);
13
+ }
14
+
15
+ Object.entries(schema.properties).forEach(([key, value]) => {
16
+ validateSchemaOverride(value, `${path}.${key}`);
17
+ });
18
+ }
19
+
20
+ if (schema.items != null) {
21
+ validateSchemaOverride(schema.items, `${path}[]`);
22
+ }
23
+
24
+ if (schema.oneOf != null) {
25
+ if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) {
26
+ throw new Error(`oneOf свойства «${path}» должен содержать непустой массив`);
27
+ }
28
+
29
+ schema.oneOf.forEach((value, index) => {
30
+ validateSchemaOverride(value, `${path}.oneOf[${index}]`);
31
+ });
32
+ }
33
+ };
34
+
35
+ export const validateSchemaConfig = (schemaConfig, resources) => {
36
+ if (
37
+ Object.hasOwn(schemaConfig, '$info') &&
38
+ (!isObject(schemaConfig.$info) || !['title', 'version'].every((key) => typeof schemaConfig.$info[key] === 'string' && schemaConfig.$info[key].trim() !== ''))
39
+ ) {
40
+ throw new Error('$info должен содержать непустые строковые поля title и version');
41
+ }
42
+
43
+ if (Object.hasOwn(schemaConfig, '$schema') && !isObject(schemaConfig.$schema)) {
44
+ throw new Error('$schema должен содержать JSON-объект');
45
+ }
46
+
47
+ const resourceConfigs = schemaConfig.$schema ?? {};
48
+
49
+ Object.entries(resourceConfigs).forEach(([resource, resourceConfig]) => {
50
+ if (!resources.includes(resource)) {
51
+ throw new Error(`В $schema указан неизвестный ресурс «${resource}»`);
52
+ }
53
+
54
+ if (!isObject(resourceConfig)) {
55
+ throw new Error(`Настройки ресурса «${resource}» должны содержать JSON-объект`);
56
+ }
57
+
58
+ if (resourceConfig.name != null && (typeof resourceConfig.name !== 'string' || resourceConfig.name.trim() === '')) {
59
+ throw new Error(`$schema.${resource}.name должен содержать непустую строку`);
60
+ }
61
+
62
+ if (resourceConfig.required != null && (!Array.isArray(resourceConfig.required) || resourceConfig.required.some((path) => typeof path !== 'string' || path === ''))) {
63
+ throw new Error(`$schema.${resource}.required должен содержать массив непустых строк`);
64
+ }
65
+
66
+ if (
67
+ resourceConfig.formats != null &&
68
+ (!isObject(resourceConfig.formats) || Object.entries(resourceConfig.formats).some(([path, format]) => path === '' || typeof format !== 'string' || format === ''))
69
+ ) {
70
+ throw new Error(`$schema.${resource}.formats должен содержать JSON-объект с непустыми строковыми путями и форматами`);
71
+ }
72
+
73
+ if (resourceConfig.properties != null) {
74
+ if (!isObject(resourceConfig.properties)) {
75
+ throw new Error(`$schema.${resource}.properties должен содержать JSON-объект`);
76
+ }
77
+
78
+ Object.entries(resourceConfig.properties).forEach(([key, value]) => {
79
+ validateSchemaOverride(value, `${resource}.${key}`);
80
+ });
81
+ }
82
+ });
83
+
84
+ return resourceConfigs;
85
+ };
86
+
87
+ export const applyConfiguredFields = (schema, resource, resourceConfig) => {
88
+ const requiredFields = Array.isArray(resourceConfig.required) ? resourceConfig.required : [];
89
+ const formats = isObject(resourceConfig.formats) ? resourceConfig.formats : {};
90
+
91
+ [...requiredFields, ...Object.keys(formats)].forEach((path) => {
92
+ if (getSchemasAtPath(schema, path.split('.')).length === 0) {
93
+ throw new Error(`Путь «${path}» из настроек ресурса «${resource}» отсутствует в итоговой схеме`);
94
+ }
95
+ });
96
+
97
+ const schemaWithFormats = Object.entries(formats).reduce((result, [path, format]) => {
98
+ const schemas = getSchemasAtPath(result, path.split('.'));
99
+
100
+ if (!schemas.some((nestedSchema) => nestedSchema.type === 'string')) {
101
+ throw new Error(`Формат «${format}» для пути «${resource}.${path}» можно применить только к строковому полю`);
102
+ }
103
+
104
+ return updateSchemasAtPath(result, path.split('.'), (nestedSchema) => (nestedSchema.type === 'string' ? { ...nestedSchema, format } : nestedSchema));
105
+ }, schema);
106
+
107
+ return applyRequiredFields(schemaWithFormats, '', new Set(requiredFields));
108
+ };
109
+
110
+ export const readSchemaConfig = async (schemaPath) => (schemaPath == null ? {} : readJsonObject(schemaPath, 'Файл схемы базы данных'));