@kollors/deep-json-server 0.2.6 → 0.3.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.
- package/README.md +56 -17
- package/README.ru.md +56 -17
- package/package.json +1 -1
- package/src/cli.js +8 -4
- package/src/openapi.js +291 -35
- package/src/query.js +150 -11
- package/src/relations.js +34 -15
- package/src/server.js +105 -37
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ A small JSON REST mock server with CRUD, pagination, deep filters and recursive
|
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
|
+
Node.js 20 or newer is required.
|
|
12
|
+
|
|
11
13
|
```bash
|
|
12
14
|
npm install --save-dev @kollors/deep-json-server
|
|
13
15
|
```
|
|
@@ -33,7 +35,7 @@ The default address is `http://127.0.0.1:4001`. You can also pass `--host` and `
|
|
|
33
35
|
|
|
34
36
|
## Example database
|
|
35
37
|
|
|
36
|
-
This example is based on a movie catalog.
|
|
38
|
+
This example is based on a movie catalog. `Gangster film` demonstrates a relationship with a parent genre.
|
|
37
39
|
|
|
38
40
|
```json
|
|
39
41
|
{
|
|
@@ -104,7 +106,7 @@ PATCH /movies/:id
|
|
|
104
106
|
DELETE /movies/:id
|
|
105
107
|
```
|
|
106
108
|
|
|
107
|
-
`POST` generates a string ID. `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. The database file must exist before startup and contain a JSON object whose resources are arrays. Top-level metadata keys prefixed with `$`, such as `$schema`, may contain non-array values.
|
|
108
110
|
|
|
109
111
|
## Pagination and sorting
|
|
110
112
|
|
|
@@ -112,7 +114,7 @@ DELETE /movies/:id
|
|
|
112
114
|
GET /movies?_page=1&_perPage=10&_sort=-id,title
|
|
113
115
|
```
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
A GET collection always returns a page object. `_page` defaults to `1`, and `_perPage` defaults to `10`:
|
|
116
118
|
|
|
117
119
|
```json
|
|
118
120
|
{
|
|
@@ -126,6 +128,8 @@ Without `_page`, a GET collection returns an array. With `_page`, it returns:
|
|
|
126
128
|
}
|
|
127
129
|
```
|
|
128
130
|
|
|
131
|
+
Both pagination parameters must be positive integers. Invalid values return `400` instead of being silently corrected.
|
|
132
|
+
|
|
129
133
|
Prefix a sort field with `-` for descending order.
|
|
130
134
|
|
|
131
135
|
## Filters
|
|
@@ -164,6 +168,8 @@ Simple query parameters are supported too:
|
|
|
164
168
|
GET /movies?title:contains=father
|
|
165
169
|
```
|
|
166
170
|
|
|
171
|
+
Simple filter values recognize JSON primitives: numbers, `true`, `false` and `null`. Values with leading zeroes, such as `001`, remain strings. Unknown operators, invalid logical conditions and filter paths that do not exist in a non-empty resource return `400`.
|
|
172
|
+
|
|
167
173
|
## Relationships
|
|
168
174
|
|
|
169
175
|
Use `_embed` to replace IDs with related records:
|
|
@@ -195,22 +201,31 @@ Relations are inferred by convention:
|
|
|
195
201
|
|
|
196
202
|
They are soft references: the server resolves them when requested but does not enforce referential integrity when data is written.
|
|
197
203
|
|
|
204
|
+
An explicit `...Id` or `...Ids` field is the source of truth. If a record also contains an outdated embedded value, `_embed` replaces it with the current related record. Relationship lookups use lazy per-request ID indexes, so each referenced resource is indexed only when needed.
|
|
205
|
+
|
|
198
206
|
## OpenAPI generation
|
|
199
207
|
|
|
200
208
|
Create a small configuration file next to the database, for example `mock/database-schema.json`:
|
|
201
209
|
|
|
202
210
|
```json
|
|
203
211
|
{
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"coverSrc": "uri"
|
|
208
|
-
}
|
|
212
|
+
"$info": {
|
|
213
|
+
"title": "Movie Catalog API",
|
|
214
|
+
"version": "1.0.0"
|
|
209
215
|
},
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"
|
|
216
|
+
"$schema": {
|
|
217
|
+
"movies": {
|
|
218
|
+
"required": ["actors", "actors.genreIds", "actors.userId", "publisherIds", "title"],
|
|
219
|
+
"formats": {
|
|
220
|
+
"coverSrc": "uri"
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
"users": {
|
|
224
|
+
"required": ["bornAt", "fullName"],
|
|
225
|
+
"formats": {
|
|
226
|
+
"avatarSrc": "uri",
|
|
227
|
+
"bornAt": "date"
|
|
228
|
+
}
|
|
214
229
|
}
|
|
215
230
|
}
|
|
216
231
|
}
|
|
@@ -219,17 +234,41 @@ Create a small configuration file next to the database, for example `mock/databa
|
|
|
219
234
|
Generate an OpenAPI 3.0.3 file and exit:
|
|
220
235
|
|
|
221
236
|
```bash
|
|
222
|
-
deep-json-server mock/database.json --generate mock/database-schema.json mock/openapi-schema.yaml
|
|
237
|
+
deep-json-server mock/database.json --generate mock/database-schema.json mock/openapi-schema.yaml --host 127.0.0.1 --port 4001
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The generator infers resources and field types from all database records. Every inferred field is optional by default, while a top-level `id` present in the resulting resource schema is always required. Add other required fields to `required`; nested fields use dot paths such as `actors.userId`. The `formats` object adds OpenAPI formats such as `date` and `uri`.
|
|
241
|
+
|
|
242
|
+
Different value types are inferred independently and combined through `oneOf`. Configuration is validated before generation: `$info`, resource and schema names, and the structure of `properties` are validated, while paths from `required` and `formats` must exist in the resulting schema.
|
|
243
|
+
|
|
244
|
+
Use `properties` to describe fields that cannot be inferred, particularly for an empty resource. Explicit properties are merged with inferred properties:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"$schema": {
|
|
249
|
+
"reviews": {
|
|
250
|
+
"properties": {
|
|
251
|
+
"rating": { "type": "integer", "minimum": 1, "maximum": 5 },
|
|
252
|
+
"text": { "type": "string" }
|
|
253
|
+
},
|
|
254
|
+
"required": ["rating"]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
223
258
|
```
|
|
224
259
|
|
|
225
|
-
|
|
260
|
+
An empty resource still receives a required string `id` property because IDs created by the server are strings. Generation stops with an actionable error when resources produce duplicate schema names or operation IDs; use an explicit `name` to resolve schema-name collisions.
|
|
261
|
+
|
|
262
|
+
`$info` becomes the OpenAPI `info` object, while resource settings live under `$schema`. The OpenAPI `servers` entry is generated automatically from `--host` and `--port`, their `HOST` and `PORT` environment variable equivalents, or the default `http://127.0.0.1:4001`.
|
|
226
263
|
|
|
227
264
|
Use `name` when a resource needs an explicit schema name instead of the automatically singularized name:
|
|
228
265
|
|
|
229
266
|
```json
|
|
230
267
|
{
|
|
231
|
-
"
|
|
232
|
-
"
|
|
268
|
+
"$schema": {
|
|
269
|
+
"equipment": {
|
|
270
|
+
"name": "Equipment"
|
|
271
|
+
}
|
|
233
272
|
}
|
|
234
273
|
}
|
|
235
274
|
```
|
|
@@ -249,7 +288,7 @@ await server.close();
|
|
|
249
288
|
|
|
250
289
|
await startServer({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001 });
|
|
251
290
|
|
|
252
|
-
await generateOpenApi({ databasePath: 'mock/database.json', schemaPath: 'mock/database-schema.json', outputPath: 'mock/openapi-schema.yaml' });
|
|
291
|
+
await generateOpenApi({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001, schemaPath: 'mock/database-schema.json', outputPath: 'mock/openapi-schema.yaml' });
|
|
253
292
|
```
|
|
254
293
|
|
|
255
294
|
`createServer()` is useful for tests because it returns a Fastify instance without opening a network port.
|
package/README.ru.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
## Установка
|
|
10
10
|
|
|
11
|
+
Требуется Node.js 20 или новее.
|
|
12
|
+
|
|
11
13
|
```bash
|
|
12
14
|
npm install --save-dev @kollors/deep-json-server
|
|
13
15
|
```
|
|
@@ -33,7 +35,7 @@ npm run mock
|
|
|
33
35
|
|
|
34
36
|
## Пример базы данных
|
|
35
37
|
|
|
36
|
-
Пример основан на каталоге фильмов.
|
|
38
|
+
Пример основан на каталоге фильмов. `Гангстерский фильм` демонстрирует связь с родительским жанром.
|
|
37
39
|
|
|
38
40
|
```json
|
|
39
41
|
{
|
|
@@ -104,7 +106,7 @@ PATCH /movies/:id
|
|
|
104
106
|
DELETE /movies/:id
|
|
105
107
|
```
|
|
106
108
|
|
|
107
|
-
`POST` генерирует строковый ID. `PUT`, `PATCH` и `DELETE` сохраняют изменения в JSON-файле.
|
|
109
|
+
`POST` генерирует строковый ID, а `PUT` и `PATCH` сохраняют исходный тип ID. Все операции записи — `POST`, `PUT`, `PATCH` и `DELETE` — сохраняют изменения в JSON-файле. Файл базы должен существовать до запуска и содержать JSON-объект, ресурсы которого являются массивами. Служебные ключи верхнего уровня с префиксом `$`, например `$schema`, могут содержать значения других типов.
|
|
108
110
|
|
|
109
111
|
## Пагинация и сортировка
|
|
110
112
|
|
|
@@ -112,7 +114,7 @@ DELETE /movies/:id
|
|
|
112
114
|
GET /movies?_page=1&_perPage=10&_sort=-id,title
|
|
113
115
|
```
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
GET-запрос к коллекции всегда возвращает объект страницы. По умолчанию `_page` равен `1`, а `_perPage` — `10`:
|
|
116
118
|
|
|
117
119
|
```json
|
|
118
120
|
{
|
|
@@ -126,6 +128,8 @@ GET /movies?_page=1&_perPage=10&_sort=-id,title
|
|
|
126
128
|
}
|
|
127
129
|
```
|
|
128
130
|
|
|
131
|
+
Оба параметра пагинации должны быть положительными целыми числами. При некорректном значении сервер возвращает `400`, а не исправляет его автоматически.
|
|
132
|
+
|
|
129
133
|
Префикс `-` перед полем включает сортировку по убыванию.
|
|
130
134
|
|
|
131
135
|
## Фильтры
|
|
@@ -164,6 +168,8 @@ GET /movies?_where={"title":{"contains":"отец"}}
|
|
|
164
168
|
GET /movies?title:contains=отец
|
|
165
169
|
```
|
|
166
170
|
|
|
171
|
+
В простых фильтрах распознаются JSON-примитивы: числа, `true`, `false` и `null`. Значения с ведущими нулями, например `001`, остаются строками. Неизвестные операторы, некорректные логические условия и отсутствующие в непустом ресурсе пути фильтра возвращают `400`.
|
|
172
|
+
|
|
167
173
|
## Связи
|
|
168
174
|
|
|
169
175
|
Используйте `_embed`, чтобы заменить ID связанными записями:
|
|
@@ -195,22 +201,31 @@ GET /countries/1?_embed=users
|
|
|
195
201
|
|
|
196
202
|
Это мягкие ссылки: сервер загружает их по запросу, но не проверяет ссылочную целостность при записи данных.
|
|
197
203
|
|
|
204
|
+
Явное поле `...Id` или `...Ids` считается источником истины. Если в записи также сохранено устаревшее вложенное значение, `_embed` заменяет его актуальной связанной записью. Для поиска связей лениво создаются ID-индексы только используемых в текущем запросе ресурсов.
|
|
205
|
+
|
|
198
206
|
## Генерация OpenAPI
|
|
199
207
|
|
|
200
208
|
Создайте рядом с базой небольшой файл конфигурации, например `mock/database-schema.json`:
|
|
201
209
|
|
|
202
210
|
```json
|
|
203
211
|
{
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"coverSrc": "uri"
|
|
208
|
-
}
|
|
212
|
+
"$info": {
|
|
213
|
+
"title": "API каталога фильмов",
|
|
214
|
+
"version": "1.0.0"
|
|
209
215
|
},
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"
|
|
216
|
+
"$schema": {
|
|
217
|
+
"movies": {
|
|
218
|
+
"required": ["actors", "actors.genreIds", "actors.userId", "publisherIds", "title"],
|
|
219
|
+
"formats": {
|
|
220
|
+
"coverSrc": "uri"
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
"users": {
|
|
224
|
+
"required": ["bornAt", "fullName"],
|
|
225
|
+
"formats": {
|
|
226
|
+
"avatarSrc": "uri",
|
|
227
|
+
"bornAt": "date"
|
|
228
|
+
}
|
|
214
229
|
}
|
|
215
230
|
}
|
|
216
231
|
}
|
|
@@ -219,17 +234,41 @@ GET /countries/1?_embed=users
|
|
|
219
234
|
Сгенерируйте OpenAPI 3.0.3 и завершите работу:
|
|
220
235
|
|
|
221
236
|
```bash
|
|
222
|
-
deep-json-server mock/database.json --generate mock/database-schema.json mock/openapi-schema.yaml
|
|
237
|
+
deep-json-server mock/database.json --generate mock/database-schema.json mock/openapi-schema.yaml --host 127.0.0.1 --port 4001
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Генератор определяет ресурсы и типы полей по всем записям базы. По умолчанию все найденные поля необязательные, а поле `id` верхнего уровня, присутствующее в итоговой схеме ресурса, всегда обязательное. Остальные обязательные поля перечисляются в `required`; для вложенных полей используются пути через точку, например `actors.userId`. Объект `formats` добавляет форматы OpenAPI, например `date` и `uri`.
|
|
241
|
+
|
|
242
|
+
Разные типы значений определяются независимо и объединяются через `oneOf`. Перед генерацией проверяются `$info`, имена ресурсов и схем, а также структура `properties`; пути из `required` и `formats` должны существовать в итоговой схеме.
|
|
243
|
+
|
|
244
|
+
Используйте `properties`, чтобы описать поля, которые невозможно определить автоматически, особенно у пустого ресурса. Явно заданные свойства объединяются с найденными автоматически:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"$schema": {
|
|
249
|
+
"reviews": {
|
|
250
|
+
"properties": {
|
|
251
|
+
"rating": { "type": "integer", "minimum": 1, "maximum": 5 },
|
|
252
|
+
"text": { "type": "string" }
|
|
253
|
+
},
|
|
254
|
+
"required": ["rating"]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
223
258
|
```
|
|
224
259
|
|
|
225
|
-
|
|
260
|
+
Пустой ресурс всё равно получает обязательное строковое поле `id`, поскольку сервер создаёт строковые ID. При совпадении имён схем или `operationId` генерация завершается понятной ошибкой; коллизию имён схем можно устранить с помощью явного `name`.
|
|
261
|
+
|
|
262
|
+
`$info` становится объектом `info` в OpenAPI, а настройки ресурсов находятся внутри `$schema`. Поле `servers` в OpenAPI формируется автоматически из параметров `--host` и `--port`, соответствующих переменных окружения `HOST` и `PORT` или адреса по умолчанию `http://127.0.0.1:4001`.
|
|
226
263
|
|
|
227
264
|
Используйте `name`, если ресурсу нужно явно задать имя схемы вместо автоматически полученного имени в единственном числе:
|
|
228
265
|
|
|
229
266
|
```json
|
|
230
267
|
{
|
|
231
|
-
"
|
|
232
|
-
"
|
|
268
|
+
"$schema": {
|
|
269
|
+
"equipment": {
|
|
270
|
+
"name": "Equipment"
|
|
271
|
+
}
|
|
233
272
|
}
|
|
234
273
|
}
|
|
235
274
|
```
|
|
@@ -249,7 +288,7 @@ await server.close();
|
|
|
249
288
|
|
|
250
289
|
await startServer({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001 });
|
|
251
290
|
|
|
252
|
-
await generateOpenApi({ databasePath: 'mock/database.json', schemaPath: 'mock/database-schema.json', outputPath: 'mock/openapi-schema.yaml' });
|
|
291
|
+
await generateOpenApi({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001, schemaPath: 'mock/database-schema.json', outputPath: 'mock/openapi-schema.yaml' });
|
|
253
292
|
```
|
|
254
293
|
|
|
255
294
|
`createServer()` удобен для тестов: он возвращает экземпляр Fastify, не открывая сетевой порт.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ const HELP = `Deep JSON Server
|
|
|
6
6
|
|
|
7
7
|
Использование:
|
|
8
8
|
deep-json-server <database.json> [--host <host>] [--port <port>]
|
|
9
|
-
deep-json-server <database.json> --generate <database-schema.json> <openapi-schema.yaml>
|
|
9
|
+
deep-json-server <database.json> --generate <database-schema.json> <openapi-schema.yaml> [--host <host>] [--port <port>]
|
|
10
10
|
|
|
11
11
|
Параметры:
|
|
12
12
|
--generate Сгенерировать OpenAPI и завершить работу
|
|
@@ -53,11 +53,15 @@ export async function runCli(args = process.argv.slice(2)) {
|
|
|
53
53
|
const schemaPath = args[generateIndex + 1];
|
|
54
54
|
const outputPath = args[generateIndex + 2];
|
|
55
55
|
|
|
56
|
-
if (generateIndex !== 1 || schemaPath == null || outputPath == null
|
|
57
|
-
throw new Error('Используйте: deep-json-server <database.json> --generate <database-schema.json> <openapi-schema.yaml>');
|
|
56
|
+
if (generateIndex !== 1 || schemaPath == null || outputPath == null) {
|
|
57
|
+
throw new Error('Используйте: deep-json-server <database.json> --generate <database-schema.json> <openapi-schema.yaml> [--host <host>] [--port <port>]');
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
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);
|
|
63
|
+
|
|
64
|
+
await generateOpenApi({ databasePath, host, outputPath, port, schemaPath });
|
|
61
65
|
process.stdout.write(`OpenAPI-схема сохранена в ${outputPath}\n`);
|
|
62
66
|
return;
|
|
63
67
|
}
|
package/src/openapi.js
CHANGED
|
@@ -13,6 +13,22 @@ const readJson = async(path, label) => {
|
|
|
13
13
|
return value;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
const getServerUrl = (host, port) => {
|
|
17
|
+
const serverPort = Number(port);
|
|
18
|
+
|
|
19
|
+
if (typeof host !== 'string' || host === '') {
|
|
20
|
+
throw new Error('Адрес сервера не должен быть пустым');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65_535) {
|
|
24
|
+
throw new Error('Порт должен быть целым числом от 1 до 65535');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const serverHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
28
|
+
|
|
29
|
+
return `http://${serverHost}:${serverPort}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
16
32
|
const mergeSchemas = (schemas) => {
|
|
17
33
|
const uniqueSchemas = [...new Map(schemas.map((schema) => [JSON.stringify(schema), schema])).values()];
|
|
18
34
|
const nullable = uniqueSchemas.some((schema) => schema.type === 'null');
|
|
@@ -29,50 +45,105 @@ const mergeSchemas = (schemas) => {
|
|
|
29
45
|
return { oneOf: nonNullSchemas, ...(nullable && { nullable: true }) };
|
|
30
46
|
};
|
|
31
47
|
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
|
|
48
|
+
const mergeSchemaOverrides = (schema, overrides) => {
|
|
49
|
+
if (!isObject(overrides)) {
|
|
50
|
+
return schema;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = { ...schema, ...overrides };
|
|
54
|
+
|
|
55
|
+
if (Object.hasOwn(overrides, 'type') && !Object.hasOwn(overrides, 'oneOf')) {
|
|
56
|
+
delete result.oneOf;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isObject(schema.properties) || isObject(overrides.properties)) {
|
|
60
|
+
const properties = isObject(schema.properties) ? { ...schema.properties } : {};
|
|
61
|
+
|
|
62
|
+
Object.entries(isObject(overrides.properties) ? overrides.properties : {}).forEach(([key, value]) => {
|
|
63
|
+
properties[key] = mergeSchemaOverrides(properties[key] ?? {}, value);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
result.properties = properties;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isObject(schema.items) && isObject(overrides.items)) {
|
|
70
|
+
result.items = mergeSchemaOverrides(schema.items, overrides.items);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const applyRequiredFields = (schema, path, requiredFields) => {
|
|
77
|
+
if (Array.isArray(schema.oneOf)) {
|
|
78
|
+
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => applyRequiredFields(nestedSchema, path, requiredFields)) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (schema.type === 'array') {
|
|
82
|
+
return { ...schema, items: applyRequiredFields(schema.items ?? {}, path, requiredFields) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (schema.type !== 'object' || !isObject(schema.properties)) {
|
|
86
|
+
return schema;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const properties = Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => {
|
|
90
|
+
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
91
|
+
|
|
92
|
+
return [key, applyRequiredFields(value, fieldPath, requiredFields)];
|
|
93
|
+
}));
|
|
94
|
+
const required = Object.keys(properties).filter((key) => {
|
|
95
|
+
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
96
|
+
|
|
97
|
+
return path === '' && key === 'id' || requiredFields.has(fieldPath);
|
|
98
|
+
});
|
|
99
|
+
const result = { ...schema, properties };
|
|
100
|
+
|
|
101
|
+
delete result.required;
|
|
102
|
+
|
|
103
|
+
return required.length === 0 ? result : { ...result, required };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const inferSchema = (values) => {
|
|
107
|
+
const schemas = [];
|
|
108
|
+
const arrays = values.filter(Array.isArray);
|
|
109
|
+
const objects = values.filter(isObject);
|
|
110
|
+
|
|
111
|
+
if (arrays.length > 0) {
|
|
112
|
+
const items = arrays.flat();
|
|
35
113
|
|
|
36
|
-
|
|
114
|
+
schemas.push({ items: items.length === 0 ? {} : inferSchema(items), type: 'array' });
|
|
37
115
|
}
|
|
38
116
|
|
|
39
|
-
if (
|
|
40
|
-
|
|
117
|
+
if (objects.length > 0) {
|
|
118
|
+
schemas.push(inferObjectSchema(objects));
|
|
41
119
|
}
|
|
42
120
|
|
|
43
|
-
|
|
121
|
+
values.filter((value) => !Array.isArray(value) && !isObject(value)).forEach((value) => {
|
|
44
122
|
if (value === null) {
|
|
45
|
-
|
|
123
|
+
schemas.push({ type: 'null' });
|
|
124
|
+
return;
|
|
46
125
|
}
|
|
47
126
|
|
|
48
127
|
if (typeof value === 'number') {
|
|
49
|
-
|
|
128
|
+
schemas.push({ type: Number.isInteger(value) ? 'integer' : 'number' });
|
|
129
|
+
return;
|
|
50
130
|
}
|
|
51
131
|
|
|
52
|
-
|
|
53
|
-
const format = options.formats[path];
|
|
54
|
-
|
|
55
|
-
return typeof value === 'string' && format != null ? { ...schema, format } : schema;
|
|
132
|
+
schemas.push({ type: typeof value });
|
|
56
133
|
});
|
|
57
134
|
|
|
58
135
|
return mergeSchemas(schemas);
|
|
59
136
|
};
|
|
60
137
|
|
|
61
|
-
function inferObjectSchema(values
|
|
138
|
+
function inferObjectSchema(values) {
|
|
62
139
|
const keys = [...new Set(values.flatMap((value) => Object.keys(value)))].sort((left, right) => left === 'id' ? -1 : right === 'id' ? 1 : left.localeCompare(right));
|
|
63
140
|
const properties = Object.fromEntries(keys.map((key) => {
|
|
64
|
-
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
65
141
|
const fieldValues = values.filter((value) => Object.hasOwn(value, key)).map((value) => value[key]);
|
|
66
142
|
|
|
67
|
-
return [key, inferSchema(fieldValues
|
|
143
|
+
return [key, inferSchema(fieldValues)];
|
|
68
144
|
}));
|
|
69
|
-
const required = keys.filter((key) => {
|
|
70
|
-
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
71
|
-
|
|
72
|
-
return !options.optional.has(fieldPath) && values.every((value) => Object.hasOwn(value, key));
|
|
73
|
-
});
|
|
74
145
|
|
|
75
|
-
return { properties, type: 'object'
|
|
146
|
+
return { properties, type: 'object' };
|
|
76
147
|
}
|
|
77
148
|
|
|
78
149
|
const resolveRelationResource = (resources, relation, sourceResource) => {
|
|
@@ -86,6 +157,10 @@ const resolveRelationResource = (resources, relation, sourceResource) => {
|
|
|
86
157
|
};
|
|
87
158
|
|
|
88
159
|
const addRelationSchemas = (schema, resources, componentNames, sourceResource) => {
|
|
160
|
+
if (Array.isArray(schema.oneOf)) {
|
|
161
|
+
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => addRelationSchemas(nestedSchema, resources, componentNames, sourceResource)) };
|
|
162
|
+
}
|
|
163
|
+
|
|
89
164
|
if (schema.type === 'array') {
|
|
90
165
|
return { ...schema, items: addRelationSchemas(schema.items, resources, componentNames, sourceResource) };
|
|
91
166
|
}
|
|
@@ -119,6 +194,147 @@ const addRelationSchemas = (schema, resources, componentNames, sourceResource) =
|
|
|
119
194
|
return { ...schema, properties };
|
|
120
195
|
};
|
|
121
196
|
|
|
197
|
+
const getSchemasAtPath = (schema, keys) => {
|
|
198
|
+
if (Array.isArray(schema.oneOf)) {
|
|
199
|
+
return schema.oneOf.flatMap((nestedSchema) => getSchemasAtPath(nestedSchema, keys));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (schema.type === 'array') {
|
|
203
|
+
return getSchemasAtPath(schema.items ?? {}, keys);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (keys.length === 0) {
|
|
207
|
+
return [schema];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (schema.type !== 'object' || !isObject(schema.properties) || !Object.hasOwn(schema.properties, keys[0])) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return getSchemasAtPath(schema.properties[keys[0]], keys.slice(1));
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const updateSchemasAtPath = (schema, keys, update) => {
|
|
218
|
+
if (Array.isArray(schema.oneOf)) {
|
|
219
|
+
return { ...schema, oneOf: schema.oneOf.map((nestedSchema) => updateSchemasAtPath(nestedSchema, keys, update)) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (schema.type === 'array') {
|
|
223
|
+
return { ...schema, items: updateSchemasAtPath(schema.items ?? {}, keys, update) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (keys.length === 0) {
|
|
227
|
+
return update(schema);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (schema.type !== 'object' || !isObject(schema.properties) || !Object.hasOwn(schema.properties, keys[0])) {
|
|
231
|
+
return schema;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
...schema,
|
|
236
|
+
properties: {
|
|
237
|
+
...schema.properties,
|
|
238
|
+
[keys[0]]: updateSchemasAtPath(schema.properties[keys[0]], keys.slice(1), update),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const validateSchemaOverride = (schema, path) => {
|
|
244
|
+
if (!isObject(schema)) {
|
|
245
|
+
throw new Error(`OpenAPI-схема свойства «${path}» должна содержать JSON-объект`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (schema.properties != null) {
|
|
249
|
+
if (!isObject(schema.properties)) {
|
|
250
|
+
throw new Error(`properties свойства «${path}» должен содержать JSON-объект`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
Object.entries(schema.properties).forEach(([key, value]) => validateSchemaOverride(value, `${path}.${key}`));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (schema.items != null) {
|
|
257
|
+
validateSchemaOverride(schema.items, `${path}[]`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (schema.oneOf != null) {
|
|
261
|
+
if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) {
|
|
262
|
+
throw new Error(`oneOf свойства «${path}» должен содержать непустой массив`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
schema.oneOf.forEach((value, index) => validateSchemaOverride(value, `${path}.oneOf[${index}]`));
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const validateSchemaConfig = (schemaConfig, resources) => {
|
|
270
|
+
if (Object.hasOwn(schemaConfig, '$info')) {
|
|
271
|
+
if (!isObject(schemaConfig.$info) || !['title', 'version'].every((key) => typeof schemaConfig.$info[key] === 'string' && schemaConfig.$info[key].trim() !== '')) {
|
|
272
|
+
throw new Error('$info должен содержать непустые строковые поля title и version');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (Object.hasOwn(schemaConfig, '$schema') && !isObject(schemaConfig.$schema)) {
|
|
277
|
+
throw new Error('$schema должен содержать JSON-объект');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const resourceConfigs = schemaConfig.$schema ?? {};
|
|
281
|
+
|
|
282
|
+
Object.entries(resourceConfigs).forEach(([resource, resourceConfig]) => {
|
|
283
|
+
if (!resources.includes(resource)) {
|
|
284
|
+
throw new Error(`В $schema указан неизвестный ресурс «${resource}»`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!isObject(resourceConfig)) {
|
|
288
|
+
throw new Error(`Настройки ресурса «${resource}» должны содержать JSON-объект`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (resourceConfig.name != null && (typeof resourceConfig.name !== 'string' || resourceConfig.name.trim() === '')) {
|
|
292
|
+
throw new Error(`$schema.${resource}.name должен содержать непустую строку`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (resourceConfig.required != null && (!Array.isArray(resourceConfig.required) || resourceConfig.required.some((path) => typeof path !== 'string' || path === ''))) {
|
|
296
|
+
throw new Error(`$schema.${resource}.required должен содержать массив непустых строк`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (resourceConfig.formats != null && (!isObject(resourceConfig.formats) || Object.entries(resourceConfig.formats).some(([path, format]) => path === '' || typeof format !== 'string' || format === ''))) {
|
|
300
|
+
throw new Error(`$schema.${resource}.formats должен содержать JSON-объект с непустыми строковыми путями и форматами`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (resourceConfig.properties != null) {
|
|
304
|
+
if (!isObject(resourceConfig.properties)) {
|
|
305
|
+
throw new Error(`$schema.${resource}.properties должен содержать JSON-объект`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
Object.entries(resourceConfig.properties).forEach(([key, value]) => validateSchemaOverride(value, `${resource}.${key}`));
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return resourceConfigs;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const applyConfiguredFields = (schema, resource, resourceConfig) => {
|
|
316
|
+
const requiredFields = Array.isArray(resourceConfig.required) ? resourceConfig.required : [];
|
|
317
|
+
const formats = isObject(resourceConfig.formats) ? resourceConfig.formats : {};
|
|
318
|
+
|
|
319
|
+
[...requiredFields, ...Object.keys(formats)].forEach((path) => {
|
|
320
|
+
if (getSchemasAtPath(schema, path.split('.')).length === 0) {
|
|
321
|
+
throw new Error(`Путь «${path}» из настроек ресурса «${resource}» отсутствует в итоговой схеме`);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const schemaWithFormats = Object.entries(formats).reduce((result, [path, format]) => {
|
|
326
|
+
const schemas = getSchemasAtPath(result, path.split('.'));
|
|
327
|
+
|
|
328
|
+
if (!schemas.some((nestedSchema) => nestedSchema.type === 'string')) {
|
|
329
|
+
throw new Error(`Формат «${format}» для пути «${resource}.${path}» можно применить только к строковому полю`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return updateSchemasAtPath(result, path.split('.'), (nestedSchema) => nestedSchema.type === 'string' ? { ...nestedSchema, format } : nestedSchema);
|
|
333
|
+
}, schema);
|
|
334
|
+
|
|
335
|
+
return applyRequiredFields(schemaWithFormats, '', new Set(requiredFields));
|
|
336
|
+
};
|
|
337
|
+
|
|
122
338
|
const omitId = (schema, keepRequired) => {
|
|
123
339
|
const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).filter(([key]) => key !== 'id'));
|
|
124
340
|
const required = keepRequired ? schema.required?.filter((key) => key !== 'id') : undefined;
|
|
@@ -133,8 +349,8 @@ const omitId = (schema, keepRequired) => {
|
|
|
133
349
|
const createParameters = () => ({
|
|
134
350
|
Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
|
|
135
351
|
Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
|
|
136
|
-
Page: { in: 'query', name: '_page', required:
|
|
137
|
-
PerPage: { in: 'query', name: '_perPage', required:
|
|
352
|
+
Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
|
|
353
|
+
PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: 10, minimum: 1, type: 'integer' } },
|
|
138
354
|
Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
|
|
139
355
|
Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
|
|
140
356
|
});
|
|
@@ -194,31 +410,71 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
194
410
|
};
|
|
195
411
|
};
|
|
196
412
|
|
|
197
|
-
|
|
413
|
+
const getOperationIds = (resource) => {
|
|
414
|
+
const resourceName = toPascalCase(resource);
|
|
415
|
+
|
|
416
|
+
return [`get${resourceName}`, `post${resourceName}`, `delete${resourceName}ById`, `get${resourceName}ById`, `patch${resourceName}ById`, `put${resourceName}ById`];
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const validateGeneratedNames = (resources, componentNames) => {
|
|
420
|
+
const schemaOwners = new Map([['Error', 'встроенная схема ошибки']]);
|
|
421
|
+
const operationOwners = new Map();
|
|
422
|
+
|
|
423
|
+
resources.forEach((resource) => {
|
|
424
|
+
const componentName = componentNames[resource];
|
|
425
|
+
|
|
426
|
+
if (componentName === '') {
|
|
427
|
+
throw new Error(`Не удалось сформировать имя OpenAPI-схемы для ресурса «${resource}». Укажите $schema.${resource}.name`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
[componentName, `${componentName}Create`, `${componentName}Update`, `${componentName}Page`].forEach((schemaName) => {
|
|
431
|
+
const owner = schemaOwners.get(schemaName);
|
|
432
|
+
|
|
433
|
+
if (owner != null) {
|
|
434
|
+
throw new Error(`Имя OpenAPI-схемы «${schemaName}» используется ресурсами «${owner}» и «${resource}». Укажите уникальный $schema.${resource}.name`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
schemaOwners.set(schemaName, resource);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
getOperationIds(resource).forEach((operationId) => {
|
|
441
|
+
const owner = operationOwners.get(operationId);
|
|
442
|
+
|
|
443
|
+
if (owner != null) {
|
|
444
|
+
throw new Error(`operationId «${operationId}» используется ресурсами «${owner}» и «${resource}». Переименуйте один из ресурсов`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
operationOwners.set(operationId, resource);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export function createOpenApiDocument(database, schemaConfig = {}, { host = '127.0.0.1', port = 4001 } = {}) {
|
|
198
453
|
if (!isObject(database) || !isObject(schemaConfig)) {
|
|
199
454
|
throw new Error('База данных и её схема должны содержать JSON-объекты');
|
|
200
455
|
}
|
|
201
456
|
|
|
202
457
|
const resources = getResourceNames(database);
|
|
458
|
+
const resourceConfigs = validateSchemaConfig(schemaConfig, resources);
|
|
203
459
|
const componentNames = Object.fromEntries(resources.map((resource) => {
|
|
204
|
-
const resourceConfig = isObject(
|
|
460
|
+
const resourceConfig = isObject(resourceConfigs[resource]) ? resourceConfigs[resource] : {};
|
|
205
461
|
const componentName = typeof resourceConfig.name === 'string' && resourceConfig.name !== '' ? resourceConfig.name : toPascalCase(singularize(resource));
|
|
206
462
|
|
|
207
463
|
return [resource, componentName];
|
|
208
464
|
}));
|
|
465
|
+
|
|
466
|
+
validateGeneratedNames(resources, componentNames);
|
|
467
|
+
|
|
209
468
|
const schemas = {
|
|
210
469
|
Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
|
|
211
470
|
};
|
|
212
471
|
|
|
213
472
|
resources.forEach((resource) => {
|
|
214
473
|
const componentName = componentNames[resource];
|
|
215
|
-
const resourceConfig = isObject(
|
|
216
|
-
const options = {
|
|
217
|
-
formats: isObject(resourceConfig.formats) ? resourceConfig.formats : {},
|
|
218
|
-
optional: new Set(Array.isArray(resourceConfig.optional) ? resourceConfig.optional : []),
|
|
219
|
-
};
|
|
474
|
+
const resourceConfig = isObject(resourceConfigs[resource]) ? resourceConfigs[resource] : {};
|
|
220
475
|
const values = database[resource].filter(isObject);
|
|
221
|
-
const
|
|
476
|
+
const inferredSchema = values.length === 0 ? { properties: { id: { type: 'string' } }, type: 'object' } : inferObjectSchema(values);
|
|
477
|
+
const rawSchema = applyConfiguredFields(mergeSchemaOverrides(inferredSchema, { properties: isObject(resourceConfig.properties) ? resourceConfig.properties : {} }), resource, resourceConfig);
|
|
222
478
|
const responseSchema = addRelationSchemas(rawSchema, resources, componentNames, resource);
|
|
223
479
|
|
|
224
480
|
schemas[componentName] = responseSchema;
|
|
@@ -244,15 +500,15 @@ export function createOpenApiDocument(database, schemaConfig = {}) {
|
|
|
244
500
|
info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
|
|
245
501
|
openapi: '3.0.3',
|
|
246
502
|
paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource]))),
|
|
247
|
-
servers:
|
|
503
|
+
servers: [{ url: getServerUrl(host, port) }],
|
|
248
504
|
tags: resources.map((resource) => ({ name: resource })),
|
|
249
505
|
};
|
|
250
506
|
}
|
|
251
507
|
|
|
252
|
-
export async function generateOpenApi({ databasePath, outputPath, schemaPath }) {
|
|
508
|
+
export async function generateOpenApi({ databasePath, host, outputPath, port, schemaPath }) {
|
|
253
509
|
const database = await readJson(databasePath, 'База данных');
|
|
254
510
|
const schemaConfig = await readJson(schemaPath, 'Схема базы данных');
|
|
255
|
-
const document = createOpenApiDocument(database, schemaConfig);
|
|
511
|
+
const document = createOpenApiDocument(database, schemaConfig, { host, port });
|
|
256
512
|
const resolvedOutputPath = resolve(outputPath);
|
|
257
513
|
|
|
258
514
|
await mkdir(dirname(resolvedOutputPath), { recursive: true });
|
package/src/query.js
CHANGED
|
@@ -2,6 +2,19 @@ import { createHttpError, isEqual, isObject, isSafeKey, toArray } from './utils.
|
|
|
2
2
|
|
|
3
3
|
const FIELD_OPERATORS = new Set(['contains', 'endsWith', 'eq', 'every', 'gt', 'gte', 'in', 'lt', 'lte', 'ne', 'none', 'not', 'some', 'startsWith']);
|
|
4
4
|
const RESERVED_QUERY_KEYS = new Set(['_embed', '_page', '_perPage', '_sort', '_where']);
|
|
5
|
+
const NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
6
|
+
|
|
7
|
+
const isFilterEqual = (left, right) => {
|
|
8
|
+
if (isEqual(left, right)) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (typeof left === 'number' && typeof right === 'string' && NUMBER_PATTERN.test(right)) {
|
|
13
|
+
return left === Number(right);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return typeof right === 'number' && typeof left === 'string' && NUMBER_PATTERN.test(left) && right === Number(left);
|
|
17
|
+
};
|
|
5
18
|
|
|
6
19
|
const compareValues = (left, right) => {
|
|
7
20
|
if (Object.is(left, right)) {
|
|
@@ -32,11 +45,11 @@ const matchesOperator = (field, operator, expectedValue) => {
|
|
|
32
45
|
case 'contains':
|
|
33
46
|
return typeof field === 'string'
|
|
34
47
|
? field.toLowerCase().includes(String(expectedValue).toLowerCase())
|
|
35
|
-
: Array.isArray(field) && field.some((value) =>
|
|
48
|
+
: Array.isArray(field) && field.some((value) => isFilterEqual(value, expectedValue));
|
|
36
49
|
case 'endsWith':
|
|
37
50
|
return typeof field === 'string' && field.toLowerCase().endsWith(String(expectedValue).toLowerCase());
|
|
38
51
|
case 'eq':
|
|
39
|
-
return
|
|
52
|
+
return isFilterEqual(field, expectedValue);
|
|
40
53
|
case 'every':
|
|
41
54
|
return Array.isArray(field) && field.every((value) => matchesValue(value, expectedValue));
|
|
42
55
|
case 'gt':
|
|
@@ -47,15 +60,15 @@ const matchesOperator = (field, operator, expectedValue) => {
|
|
|
47
60
|
const expectedValues = toArray(expectedValue);
|
|
48
61
|
|
|
49
62
|
return Array.isArray(field)
|
|
50
|
-
? field.some((value) => expectedValues.some((expectedItem) =>
|
|
51
|
-
: expectedValues.some((expectedItem) =>
|
|
63
|
+
? field.some((value) => expectedValues.some((expectedItem) => isFilterEqual(value, expectedItem)))
|
|
64
|
+
: expectedValues.some((expectedItem) => isFilterEqual(field, expectedItem));
|
|
52
65
|
}
|
|
53
66
|
case 'lt':
|
|
54
67
|
return field != null && field < expectedValue;
|
|
55
68
|
case 'lte':
|
|
56
69
|
return field != null && field <= expectedValue;
|
|
57
70
|
case 'ne':
|
|
58
|
-
return !
|
|
71
|
+
return !isFilterEqual(field, expectedValue);
|
|
59
72
|
case 'none':
|
|
60
73
|
return Array.isArray(field) && !field.some((value) => matchesValue(value, expectedValue));
|
|
61
74
|
case 'not':
|
|
@@ -71,7 +84,7 @@ const matchesOperator = (field, operator, expectedValue) => {
|
|
|
71
84
|
|
|
72
85
|
function matchesValue(field, condition) {
|
|
73
86
|
if (!isObject(condition)) {
|
|
74
|
-
return
|
|
87
|
+
return isFilterEqual(field, condition);
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
const conditionEntries = Object.entries(condition);
|
|
@@ -108,6 +121,10 @@ function matchesWhere(value, where) {
|
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
const parsePrimitive = (value) => {
|
|
124
|
+
if (typeof value !== 'string') {
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
111
128
|
if (value === 'true') {
|
|
112
129
|
return true;
|
|
113
130
|
}
|
|
@@ -116,7 +133,11 @@ const parsePrimitive = (value) => {
|
|
|
116
133
|
return false;
|
|
117
134
|
}
|
|
118
135
|
|
|
119
|
-
|
|
136
|
+
if (value === 'null') {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return NUMBER_PATTERN.test(value) && Number.isFinite(Number(value)) ? Number(value) : value;
|
|
120
141
|
};
|
|
121
142
|
|
|
122
143
|
const parseFilterKey = (key) => {
|
|
@@ -126,7 +147,11 @@ const parseFilterKey = (key) => {
|
|
|
126
147
|
const path = key.slice(0, colonIndex);
|
|
127
148
|
const operator = key.slice(colonIndex + 1);
|
|
128
149
|
|
|
129
|
-
|
|
150
|
+
if (!FIELD_OPERATORS.has(operator)) {
|
|
151
|
+
throw createHttpError(400, `Неизвестный оператор «${operator}» в фильтре «${key}»`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { operator, path };
|
|
130
155
|
}
|
|
131
156
|
|
|
132
157
|
const legacyOperator = key.match(/^(.*)_([a-zA-Z]+)$/);
|
|
@@ -139,10 +164,10 @@ const parseFilterKey = (key) => {
|
|
|
139
164
|
};
|
|
140
165
|
|
|
141
166
|
const setWhereOperator = (where, path, operator, value) => {
|
|
142
|
-
const keys = path.split('.')
|
|
167
|
+
const keys = path.split('.');
|
|
143
168
|
|
|
144
|
-
if (keys.
|
|
145
|
-
|
|
169
|
+
if (keys.some((key) => key === '' || !isSafeKey(key))) {
|
|
170
|
+
throw createHttpError(400, `Недопустимый путь фильтра «${path}»`);
|
|
146
171
|
}
|
|
147
172
|
|
|
148
173
|
const fieldKey = keys.pop();
|
|
@@ -191,6 +216,120 @@ export const parseWhere = (query) => {
|
|
|
191
216
|
return where;
|
|
192
217
|
};
|
|
193
218
|
|
|
219
|
+
const validateCondition = (condition, samples, path) => {
|
|
220
|
+
if (!isObject(condition)) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
Object.entries(condition).forEach(([key, value]) => {
|
|
225
|
+
if (key === 'and' || key === 'or') {
|
|
226
|
+
if (!Array.isArray(value) || key === 'or' && value.length === 0 || value.some((nestedWhere) => !isObject(nestedWhere))) {
|
|
227
|
+
throw createHttpError(400, `Оператор «${key}» должен содержать ${key === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
value.forEach((nestedWhere) => validateWhere(nestedWhere, samples, path));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (FIELD_OPERATORS.has(key)) {
|
|
235
|
+
if (['every', 'none', 'some'].includes(key)) {
|
|
236
|
+
if (samples.length > 0 && !samples.some(Array.isArray)) {
|
|
237
|
+
throw createHttpError(400, `Оператор «${key}» в фильтре «${path}» можно применить только к массиву`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const nestedSamples = samples.flatMap((sample) => Array.isArray(sample) ? sample : []);
|
|
241
|
+
|
|
242
|
+
validateCondition(value, nestedSamples, `${path}.${key}`);
|
|
243
|
+
} else if (key === 'not') {
|
|
244
|
+
validateCondition(value, samples, `${path}.not`);
|
|
245
|
+
} else if (['endsWith', 'startsWith'].includes(key) && samples.length > 0 && !samples.some((sample) => typeof sample === 'string')) {
|
|
246
|
+
throw createHttpError(400, `Оператор «${key}» в фильтре «${path}» можно применить только к строке`);
|
|
247
|
+
} else if (key === 'contains' && samples.length > 0 && !samples.some((sample) => typeof sample === 'string' || Array.isArray(sample))) {
|
|
248
|
+
throw createHttpError(400, `Оператор «contains» в фильтре «${path}» можно применить только к строке или массиву`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!isSafeKey(key)) {
|
|
255
|
+
throw createHttpError(400, `Недопустимый путь фильтра «${path}.${key}»`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const objectSamples = samples.filter(isObject);
|
|
259
|
+
|
|
260
|
+
if (samples.length > 0 && objectSamples.length === 0) {
|
|
261
|
+
throw createHttpError(400, `Неизвестный оператор или вложенное поле «${path}.${key}»`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const nestedSamples = objectSamples.filter((sample) => Object.hasOwn(sample, key)).map((sample) => sample[key]);
|
|
265
|
+
|
|
266
|
+
if (objectSamples.length > 0 && nestedSamples.length === 0) {
|
|
267
|
+
throw createHttpError(400, `Неизвестное поле фильтра «${path}.${key}»`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
validateCondition(value, nestedSamples, `${path}.${key}`);
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export const validateWhere = (where, items, path = '') => {
|
|
275
|
+
Object.entries(where).forEach(([key, condition]) => {
|
|
276
|
+
if (key === 'and' || key === 'or') {
|
|
277
|
+
if (!Array.isArray(condition) || key === 'or' && condition.length === 0 || condition.some((nestedWhere) => !isObject(nestedWhere))) {
|
|
278
|
+
throw createHttpError(400, `Оператор «${key}» должен содержать ${key === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
condition.forEach((nestedWhere) => validateWhere(nestedWhere, items, path));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (key === 'not') {
|
|
286
|
+
if (!isObject(condition)) {
|
|
287
|
+
throw createHttpError(400, 'Оператор «not» должен содержать JSON-объект');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
validateWhere(condition, items, path);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!isSafeKey(key)) {
|
|
295
|
+
throw createHttpError(400, `Недопустимый путь фильтра «${path === '' ? key : `${path}.${key}`}»`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
299
|
+
const objectItems = items.filter(isObject);
|
|
300
|
+
const samples = objectItems.filter((item) => Object.hasOwn(item, key)).map((item) => item[key]);
|
|
301
|
+
|
|
302
|
+
if (objectItems.length > 0 && samples.length === 0) {
|
|
303
|
+
throw createHttpError(400, `Неизвестное поле фильтра «${fieldPath}»`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
validateCondition(condition, samples, fieldPath);
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const parsePositiveInteger = (value, name, defaultValue) => {
|
|
311
|
+
if (value == null) {
|
|
312
|
+
return defaultValue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (Array.isArray(value) || !/^[1-9]\d*$/.test(String(value))) {
|
|
316
|
+
throw createHttpError(400, `Параметр ${name} должен быть положительным целым числом`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const number = Number(value);
|
|
320
|
+
|
|
321
|
+
if (!Number.isSafeInteger(number)) {
|
|
322
|
+
throw createHttpError(400, `Параметр ${name} должен быть положительным целым числом`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return number;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export const parsePagination = (query) => ({
|
|
329
|
+
page: parsePositiveInteger(query._page, '_page', 1),
|
|
330
|
+
pageSize: parsePositiveInteger(query._perPage, '_perPage', 10),
|
|
331
|
+
});
|
|
332
|
+
|
|
194
333
|
export const paginateItems = (items, page, pageSize) => {
|
|
195
334
|
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : 10;
|
|
196
335
|
const pages = Math.max(1, Math.ceil(items.length / safePageSize));
|
package/src/relations.js
CHANGED
|
@@ -45,7 +45,23 @@ const hasReference = (value, relationKeys, id) => {
|
|
|
45
45
|
});
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
const
|
|
48
|
+
const getResourceIndex = (database, resource, indexes) => {
|
|
49
|
+
if (!indexes.has(resource)) {
|
|
50
|
+
const index = new Map();
|
|
51
|
+
|
|
52
|
+
database.data[resource].forEach((item) => {
|
|
53
|
+
if (isObject(item) && item.id != null && !index.has(item.id)) {
|
|
54
|
+
index.set(item.id, item);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
indexes.set(resource, index);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return indexes.get(resource);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const findRelatedValue = (database, item, sourceResource, relation, targetResource, indexes) => {
|
|
49
65
|
const targetItems = database.data[targetResource];
|
|
50
66
|
const localRelation = findLocalRelation(item, relation, targetResource);
|
|
51
67
|
|
|
@@ -54,7 +70,8 @@ const findRelatedValue = (database, item, sourceResource, relation, targetResour
|
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
if (localRelation != null) {
|
|
57
|
-
const
|
|
73
|
+
const targetIndex = getResourceIndex(database, targetResource, indexes);
|
|
74
|
+
const relatedItems = localRelation.ids.map((id) => targetIndex.get(id)).filter((targetItem) => targetItem != null);
|
|
58
75
|
|
|
59
76
|
return localRelation.isMany ? relatedItems : relatedItems[0] ?? null;
|
|
60
77
|
}
|
|
@@ -72,29 +89,31 @@ const findRelatedValue = (database, item, sourceResource, relation, targetResour
|
|
|
72
89
|
return targetItems.filter((targetItem) => hasReference(targetItem, reverseRelationKeys, item.id));
|
|
73
90
|
};
|
|
74
91
|
|
|
75
|
-
const embedPath = (database, item, sourceResource, [relation, ...nestedRelations]) => {
|
|
92
|
+
const embedPath = (database, item, sourceResource, [relation, ...nestedRelations], indexes) => {
|
|
76
93
|
if (relation == null || !isSafeKey(relation)) {
|
|
77
94
|
return item;
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
const currentValue = item[relation];
|
|
81
|
-
const
|
|
98
|
+
const targetResource = resolveResource(database, relation, sourceResource);
|
|
99
|
+
const nestedSourceResource = targetResource ?? relation;
|
|
100
|
+
const localRelation = targetResource == null ? undefined : findLocalRelation(item, relation, targetResource);
|
|
82
101
|
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
if (localRelation == null) {
|
|
103
|
+
if (Array.isArray(currentValue)) {
|
|
104
|
+
return nestedRelations.length === 0 ? item : { ...item, [relation]: currentValue.map((value) => (isObject(value) ? embedPath(database, value, nestedSourceResource, nestedRelations, indexes) : value)) };
|
|
105
|
+
}
|
|
86
106
|
|
|
87
|
-
|
|
88
|
-
|
|
107
|
+
if (isObject(currentValue)) {
|
|
108
|
+
return nestedRelations.length === 0 ? item : { ...item, [relation]: embedPath(database, currentValue, nestedSourceResource, nestedRelations, indexes) };
|
|
109
|
+
}
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
const targetResource = resolveResource(database, relation, sourceResource);
|
|
92
|
-
|
|
93
112
|
if (targetResource == null) {
|
|
94
113
|
return item;
|
|
95
114
|
}
|
|
96
115
|
|
|
97
|
-
const relatedValue = findRelatedValue(database, item, sourceResource, relation, targetResource);
|
|
116
|
+
const relatedValue = findRelatedValue(database, item, sourceResource, relation, targetResource, indexes);
|
|
98
117
|
|
|
99
118
|
if (relatedValue == null || nestedRelations.length === 0) {
|
|
100
119
|
return relatedValue === undefined ? item : { ...item, [relation]: relatedValue };
|
|
@@ -103,8 +122,8 @@ const embedPath = (database, item, sourceResource, [relation, ...nestedRelations
|
|
|
103
122
|
return {
|
|
104
123
|
...item,
|
|
105
124
|
[relation]: Array.isArray(relatedValue)
|
|
106
|
-
? relatedValue.map((value) => embedPath(database, value, targetResource, nestedRelations))
|
|
107
|
-
: embedPath(database, relatedValue, targetResource, nestedRelations),
|
|
125
|
+
? relatedValue.map((value) => embedPath(database, value, targetResource, nestedRelations, indexes))
|
|
126
|
+
: embedPath(database, relatedValue, targetResource, nestedRelations, indexes),
|
|
108
127
|
};
|
|
109
128
|
};
|
|
110
129
|
|
|
@@ -113,4 +132,4 @@ export const parseEmbedPaths = (embed) => toArray(embed)
|
|
|
113
132
|
.map((path) => path.split('.').filter(Boolean))
|
|
114
133
|
.filter((path) => path.length > 0 && path.every(isSafeKey));
|
|
115
134
|
|
|
116
|
-
export const embedItem = (database, item, resource, embedPaths) => embedPaths.reduce((embeddedItem, path) => embedPath(database, embeddedItem, resource, path), item);
|
|
135
|
+
export const embedItem = (database, item, resource, embedPaths, indexes = new Map()) => embedPaths.reduce((embeddedItem, path) => embedPath(database, embeddedItem, resource, path, indexes), item);
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Fastify from 'fastify';
|
|
2
2
|
import { JSONFilePreset } from 'lowdb/node';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import {
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { matchesWhere, paginateItems, parsePagination, parseWhere, sortItems, validateWhere } from './query.js';
|
|
5
6
|
import { embedItem, parseEmbedPaths } from './relations.js';
|
|
6
7
|
import { createHttpError, getResourceNames, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
|
|
7
8
|
|
|
@@ -31,16 +32,73 @@ const getRequestBody = (body) => {
|
|
|
31
32
|
|
|
32
33
|
const findItem = (collection, id) => collection.find((item) => isObject(item) && String(item.id) === id);
|
|
33
34
|
|
|
35
|
+
const validateDatabase = (data) => {
|
|
36
|
+
if (!isObject(data)) {
|
|
37
|
+
throw new Error('База данных должна содержать JSON-объект');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const invalidResource = Object.entries(data).find(([key, value]) => !key.startsWith('$') && !Array.isArray(value));
|
|
41
|
+
|
|
42
|
+
if (invalidResource != null) {
|
|
43
|
+
throw new Error(`Ресурс «${invalidResource[0]}» должен содержать JSON-массив`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const readDatabaseFile = async(databasePath) => {
|
|
48
|
+
let source;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
source = await readFile(databasePath, 'utf8');
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error?.code === 'ENOENT') {
|
|
54
|
+
throw new Error(`Файл базы данных не найден: ${databasePath}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = JSON.parse(source);
|
|
61
|
+
|
|
62
|
+
validateDatabase(data);
|
|
63
|
+
|
|
64
|
+
return data;
|
|
65
|
+
};
|
|
66
|
+
|
|
34
67
|
export async function createServer({ databasePath, logger = true } = {}) {
|
|
35
|
-
const
|
|
68
|
+
const resolvedDatabasePath = resolveDatabasePath(databasePath);
|
|
69
|
+
const initialData = await readDatabaseFile(resolvedDatabasePath);
|
|
70
|
+
const database = await JSONFilePreset(resolvedDatabasePath, initialData);
|
|
36
71
|
const server = Fastify({ logger });
|
|
72
|
+
let databaseWriteQueue = Promise.resolve();
|
|
73
|
+
|
|
74
|
+
const readDatabase = async() => {
|
|
75
|
+
database.data = await readDatabaseFile(resolvedDatabasePath);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const updateDatabase = (update) => {
|
|
79
|
+
const operation = databaseWriteQueue.then(async() => {
|
|
80
|
+
await readDatabase();
|
|
81
|
+
|
|
82
|
+
const result = update();
|
|
83
|
+
|
|
84
|
+
await database.write();
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
databaseWriteQueue = operation.catch(() => undefined);
|
|
90
|
+
|
|
91
|
+
return operation;
|
|
92
|
+
};
|
|
37
93
|
|
|
38
94
|
server.addHook('onRequest', async(_request, reply) => {
|
|
39
95
|
Object.entries(CORS_HEADERS).forEach(([header, value]) => reply.header(header, value));
|
|
40
96
|
});
|
|
41
97
|
|
|
42
|
-
server.addHook('preHandler', async() => {
|
|
43
|
-
|
|
98
|
+
server.addHook('preHandler', async(request) => {
|
|
99
|
+
if (request.method === 'GET') {
|
|
100
|
+
await readDatabase();
|
|
101
|
+
}
|
|
44
102
|
});
|
|
45
103
|
|
|
46
104
|
server.options('/', async(_request, reply) => reply.code(204).send());
|
|
@@ -51,12 +109,16 @@ export async function createServer({ databasePath, logger = true } = {}) {
|
|
|
51
109
|
const collection = getCollection(database, request.params.resource);
|
|
52
110
|
const where = parseWhere(request.query);
|
|
53
111
|
const embedPaths = parseEmbedPaths(request.query._embed);
|
|
54
|
-
const
|
|
112
|
+
const relationIndexes = new Map();
|
|
113
|
+
const embeddedItems = collection.map((item) => embedItem(database, item, request.params.resource, embedPaths, relationIndexes));
|
|
114
|
+
const pagination = parsePagination(request.query);
|
|
115
|
+
|
|
116
|
+
validateWhere(where, embeddedItems);
|
|
117
|
+
|
|
55
118
|
const filteredItems = embeddedItems.filter((item) => matchesWhere(item, where));
|
|
56
119
|
const sortedItems = sortItems(filteredItems, request.query._sort);
|
|
57
|
-
const page = request.query._page == null ? undefined : Number(request.query._page);
|
|
58
120
|
|
|
59
|
-
return
|
|
121
|
+
return paginateItems(sortedItems, pagination.page, pagination.pageSize);
|
|
60
122
|
});
|
|
61
123
|
|
|
62
124
|
server.get('/:resource/:id', async(request) => {
|
|
@@ -70,59 +132,65 @@ export async function createServer({ databasePath, logger = true } = {}) {
|
|
|
70
132
|
});
|
|
71
133
|
|
|
72
134
|
server.post('/:resource', async(request, reply) => {
|
|
73
|
-
const
|
|
74
|
-
|
|
135
|
+
const item = await updateDatabase(() => {
|
|
136
|
+
const collection = getCollection(database, request.params.resource);
|
|
137
|
+
const createdItem = { ...getRequestBody(request.body), id: randomBytes(8).toString('base64url') };
|
|
138
|
+
|
|
139
|
+
collection.push(createdItem);
|
|
75
140
|
|
|
76
|
-
|
|
77
|
-
|
|
141
|
+
return createdItem;
|
|
142
|
+
});
|
|
78
143
|
|
|
79
144
|
return reply.code(201).send(item);
|
|
80
145
|
});
|
|
81
146
|
|
|
82
147
|
server.put('/:resource/:id', async(request) => {
|
|
83
|
-
|
|
84
|
-
|
|
148
|
+
return updateDatabase(() => {
|
|
149
|
+
const collection = getCollection(database, request.params.resource);
|
|
150
|
+
const currentItem = findItem(collection, request.params.id);
|
|
85
151
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
152
|
+
if (currentItem == null) {
|
|
153
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
154
|
+
}
|
|
89
155
|
|
|
90
|
-
|
|
156
|
+
const item = { ...getRequestBody(request.body), id: currentItem.id };
|
|
91
157
|
|
|
92
|
-
|
|
93
|
-
await database.write();
|
|
158
|
+
collection.splice(collection.indexOf(currentItem), 1, item);
|
|
94
159
|
|
|
95
|
-
|
|
160
|
+
return item;
|
|
161
|
+
});
|
|
96
162
|
});
|
|
97
163
|
|
|
98
164
|
server.patch('/:resource/:id', async(request) => {
|
|
99
|
-
|
|
100
|
-
|
|
165
|
+
return updateDatabase(() => {
|
|
166
|
+
const collection = getCollection(database, request.params.resource);
|
|
167
|
+
const currentItem = findItem(collection, request.params.id);
|
|
101
168
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
169
|
+
if (currentItem == null) {
|
|
170
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
171
|
+
}
|
|
105
172
|
|
|
106
|
-
|
|
173
|
+
const item = { ...currentItem, ...getRequestBody(request.body), id: currentItem.id };
|
|
107
174
|
|
|
108
|
-
|
|
109
|
-
await database.write();
|
|
175
|
+
collection.splice(collection.indexOf(currentItem), 1, item);
|
|
110
176
|
|
|
111
|
-
|
|
177
|
+
return item;
|
|
178
|
+
});
|
|
112
179
|
});
|
|
113
180
|
|
|
114
181
|
server.delete('/:resource/:id', async(request) => {
|
|
115
|
-
|
|
116
|
-
|
|
182
|
+
return updateDatabase(() => {
|
|
183
|
+
const collection = getCollection(database, request.params.resource);
|
|
184
|
+
const currentItem = findItem(collection, request.params.id);
|
|
117
185
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
186
|
+
if (currentItem == null) {
|
|
187
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
188
|
+
}
|
|
121
189
|
|
|
122
|
-
|
|
123
|
-
await database.write();
|
|
190
|
+
collection.splice(collection.indexOf(currentItem), 1);
|
|
124
191
|
|
|
125
|
-
|
|
192
|
+
return currentItem;
|
|
193
|
+
});
|
|
126
194
|
});
|
|
127
195
|
|
|
128
196
|
server.setErrorHandler((error, request, reply) => {
|