@kollors/deep-json-server 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -8
- package/README.ru.md +14 -8
- package/bin/deep-json-server.js +11 -0
- package/index.js +1 -11
- package/package.json +22 -5
- package/src/cli.js +19 -15
- package/src/constants.js +4 -0
- package/src/database.js +128 -0
- package/src/openapi/config.js +110 -0
- package/src/openapi/document.js +287 -0
- package/src/openapi/index.js +25 -0
- package/src/openapi/inference.js +187 -0
- package/src/{query.js → query/filter.js} +23 -113
- package/src/query/index.js +3 -0
- package/src/query/pagination.js +50 -0
- package/src/query/sort.js +63 -0
- package/src/relation-metadata.js +40 -0
- package/src/relations.js +62 -24
- package/src/server.js +108 -100
- package/src/utils.js +7 -18
- package/types/index.d.ts +2 -0
- package/types/src/constants.d.ts +4 -0
- package/types/src/database.d.ts +8 -0
- package/types/src/openapi/config.d.ts +3 -0
- package/types/src/openapi/document.d.ts +11 -0
- package/types/src/openapi/index.d.ts +13 -0
- package/types/src/openapi/inference.d.ts +9 -0
- package/types/src/query/filter.d.ts +3 -0
- package/types/src/query/index.d.ts +3 -0
- package/types/src/query/pagination.d.ts +13 -0
- package/types/src/query/sort.d.ts +1 -0
- package/types/src/relation-metadata.d.ts +9 -0
- package/types/src/relations.d.ts +3 -0
- package/types/src/server.d.ts +24 -0
- package/types/src/utils.d.ts +10 -0
- package/src/openapi.js +0 -520
package/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.
|
|
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.
|
|
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-файле.
|
|
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
|
|
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-декларации для всех экспортируемых функций.
|
package/index.js
CHANGED
|
@@ -1,12 +1,2 @@
|
|
|
1
|
-
|
|
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
|
+
"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": "
|
|
8
|
+
"deep-json-server": "bin/deep-json-server.js"
|
|
8
9
|
},
|
|
9
10
|
"exports": {
|
|
10
|
-
".":
|
|
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
|
-
"
|
|
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 {
|
|
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
|
|
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
|
|
19
|
+
const parseOptions = (args, allowedOptions) => {
|
|
18
20
|
const options = {};
|
|
19
21
|
|
|
20
|
-
for (let index =
|
|
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 (!
|
|
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
|
-
|
|
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(`${
|
|
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 =
|
|
61
|
-
const host = options.host ?? process.env.HOST ??
|
|
62
|
-
const port = Number(options.port ?? process.env.PORT ??
|
|
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 =
|
|
70
|
-
const host = options.host ?? process.env.HOST ??
|
|
71
|
-
const port = Number(options.port ?? process.env.PORT ??
|
|
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
|
}
|
package/src/constants.js
ADDED
package/src/database.js
ADDED
|
@@ -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, 'Файл схемы базы данных'));
|