@kollors/deep-json-server 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -4
- package/README.ru.md +32 -4
- package/package.json +1 -1
- package/src/openapi.js +274 -33
- package/src/query.js +150 -11
- package/src/relations.js +34 -15
- package/src/server.js +92 -38
- package/src/utils.js +15 -1
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 every top-level property is an array of resource records.
|
|
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,6 +201,8 @@ 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`:
|
|
@@ -229,7 +237,27 @@ Generate an OpenAPI 3.0.3 file and exit:
|
|
|
229
237
|
deep-json-server mock/database.json --generate mock/database-schema.json mock/openapi-schema.yaml --host 127.0.0.1 --port 4001
|
|
230
238
|
```
|
|
231
239
|
|
|
232
|
-
The generator infers resources and field types from all database records. Every inferred field is optional by default, while
|
|
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
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
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.
|
|
233
261
|
|
|
234
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`.
|
|
235
263
|
|
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-объект, каждое свойство верхнего уровня которого является массивом записей отдельного ресурса.
|
|
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,6 +201,8 @@ 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`:
|
|
@@ -229,7 +237,27 @@ GET /countries/1?_embed=users
|
|
|
229
237
|
deep-json-server mock/database.json --generate mock/database-schema.json mock/openapi-schema.yaml --host 127.0.0.1 --port 4001
|
|
230
238
|
```
|
|
231
239
|
|
|
232
|
-
Генератор определяет ресурсы и типы полей по всем записям базы. По умолчанию все найденные поля необязательные, а
|
|
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
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Пустой ресурс всё равно получает обязательное строковое поле `id`, поскольку сервер создаёт строковые ID. При совпадении имён схем или `operationId` генерация завершается понятной ошибкой; коллизию имён схем можно устранить с помощью явного `name`.
|
|
233
261
|
|
|
234
262
|
`$info` становится объектом `info` в OpenAPI, а настройки ресурсов находятся внутри `$schema`. Поле `servers` в OpenAPI формируется автоматически из параметров `--host` и `--port`, соответствующих переменных окружения `HOST` и `PORT` или адреса по умолчанию `http://127.0.0.1:4001`.
|
|
235
263
|
|
package/package.json
CHANGED
package/src/openapi.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { stringify } from 'yaml';
|
|
4
|
-
import { getResourceNames, isObject, singularize, toPascalCase } from './utils.js';
|
|
4
|
+
import { getResourceNames, isObject, singularize, toPascalCase, validateDatabase } from './utils.js';
|
|
5
5
|
|
|
6
6
|
const readJson = async(path, label) => {
|
|
7
7
|
const value = JSON.parse(await readFile(resolve(path), 'utf8'));
|
|
@@ -45,50 +45,105 @@ const mergeSchemas = (schemas) => {
|
|
|
45
45
|
return { oneOf: nonNullSchemas, ...(nullable && { nullable: true }) };
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
50
|
-
|
|
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();
|
|
51
113
|
|
|
52
|
-
|
|
114
|
+
schemas.push({ items: items.length === 0 ? {} : inferSchema(items), type: 'array' });
|
|
53
115
|
}
|
|
54
116
|
|
|
55
|
-
if (
|
|
56
|
-
|
|
117
|
+
if (objects.length > 0) {
|
|
118
|
+
schemas.push(inferObjectSchema(objects));
|
|
57
119
|
}
|
|
58
120
|
|
|
59
|
-
|
|
121
|
+
values.filter((value) => !Array.isArray(value) && !isObject(value)).forEach((value) => {
|
|
60
122
|
if (value === null) {
|
|
61
|
-
|
|
123
|
+
schemas.push({ type: 'null' });
|
|
124
|
+
return;
|
|
62
125
|
}
|
|
63
126
|
|
|
64
127
|
if (typeof value === 'number') {
|
|
65
|
-
|
|
128
|
+
schemas.push({ type: Number.isInteger(value) ? 'integer' : 'number' });
|
|
129
|
+
return;
|
|
66
130
|
}
|
|
67
131
|
|
|
68
|
-
|
|
69
|
-
const format = options.formats[path];
|
|
70
|
-
|
|
71
|
-
return typeof value === 'string' && format != null ? { ...schema, format } : schema;
|
|
132
|
+
schemas.push({ type: typeof value });
|
|
72
133
|
});
|
|
73
134
|
|
|
74
135
|
return mergeSchemas(schemas);
|
|
75
136
|
};
|
|
76
137
|
|
|
77
|
-
function inferObjectSchema(values
|
|
138
|
+
function inferObjectSchema(values) {
|
|
78
139
|
const keys = [...new Set(values.flatMap((value) => Object.keys(value)))].sort((left, right) => left === 'id' ? -1 : right === 'id' ? 1 : left.localeCompare(right));
|
|
79
140
|
const properties = Object.fromEntries(keys.map((key) => {
|
|
80
|
-
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
81
141
|
const fieldValues = values.filter((value) => Object.hasOwn(value, key)).map((value) => value[key]);
|
|
82
142
|
|
|
83
|
-
return [key, inferSchema(fieldValues
|
|
143
|
+
return [key, inferSchema(fieldValues)];
|
|
84
144
|
}));
|
|
85
|
-
const required = keys.filter((key) => {
|
|
86
|
-
const fieldPath = path === '' ? key : `${path}.${key}`;
|
|
87
145
|
|
|
88
|
-
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
return { properties, type: 'object', ...(required.length > 0 && { required }) };
|
|
146
|
+
return { properties, type: 'object' };
|
|
92
147
|
}
|
|
93
148
|
|
|
94
149
|
const resolveRelationResource = (resources, relation, sourceResource) => {
|
|
@@ -102,6 +157,10 @@ const resolveRelationResource = (resources, relation, sourceResource) => {
|
|
|
102
157
|
};
|
|
103
158
|
|
|
104
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
|
+
|
|
105
164
|
if (schema.type === 'array') {
|
|
106
165
|
return { ...schema, items: addRelationSchemas(schema.items, resources, componentNames, sourceResource) };
|
|
107
166
|
}
|
|
@@ -135,6 +194,147 @@ const addRelationSchemas = (schema, resources, componentNames, sourceResource) =
|
|
|
135
194
|
return { ...schema, properties };
|
|
136
195
|
};
|
|
137
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
|
+
|
|
138
338
|
const omitId = (schema, keepRequired) => {
|
|
139
339
|
const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).filter(([key]) => key !== 'id'));
|
|
140
340
|
const required = keepRequired ? schema.required?.filter((key) => key !== 'id') : undefined;
|
|
@@ -149,8 +349,8 @@ const omitId = (schema, keepRequired) => {
|
|
|
149
349
|
const createParameters = () => ({
|
|
150
350
|
Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
|
|
151
351
|
Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
|
|
152
|
-
Page: { in: 'query', name: '_page', required:
|
|
153
|
-
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' } },
|
|
154
354
|
Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
|
|
155
355
|
Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
|
|
156
356
|
});
|
|
@@ -210,19 +410,63 @@ const createResourcePaths = (resource, componentName) => {
|
|
|
210
410
|
};
|
|
211
411
|
};
|
|
212
412
|
|
|
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
|
+
|
|
213
452
|
export function createOpenApiDocument(database, schemaConfig = {}, { host = '127.0.0.1', port = 4001 } = {}) {
|
|
214
|
-
|
|
215
|
-
|
|
453
|
+
validateDatabase(database);
|
|
454
|
+
|
|
455
|
+
if (!isObject(schemaConfig)) {
|
|
456
|
+
throw new Error('Схема базы данных должна содержать JSON-объект');
|
|
216
457
|
}
|
|
217
458
|
|
|
218
459
|
const resources = getResourceNames(database);
|
|
219
|
-
const resourceConfigs =
|
|
460
|
+
const resourceConfigs = validateSchemaConfig(schemaConfig, resources);
|
|
220
461
|
const componentNames = Object.fromEntries(resources.map((resource) => {
|
|
221
462
|
const resourceConfig = isObject(resourceConfigs[resource]) ? resourceConfigs[resource] : {};
|
|
222
463
|
const componentName = typeof resourceConfig.name === 'string' && resourceConfig.name !== '' ? resourceConfig.name : toPascalCase(singularize(resource));
|
|
223
464
|
|
|
224
465
|
return [resource, componentName];
|
|
225
466
|
}));
|
|
467
|
+
|
|
468
|
+
validateGeneratedNames(resources, componentNames);
|
|
469
|
+
|
|
226
470
|
const schemas = {
|
|
227
471
|
Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
|
|
228
472
|
};
|
|
@@ -230,12 +474,9 @@ export function createOpenApiDocument(database, schemaConfig = {}, { host = '127
|
|
|
230
474
|
resources.forEach((resource) => {
|
|
231
475
|
const componentName = componentNames[resource];
|
|
232
476
|
const resourceConfig = isObject(resourceConfigs[resource]) ? resourceConfigs[resource] : {};
|
|
233
|
-
const options = {
|
|
234
|
-
formats: isObject(resourceConfig.formats) ? resourceConfig.formats : {},
|
|
235
|
-
required: new Set(Array.isArray(resourceConfig.required) ? resourceConfig.required : []),
|
|
236
|
-
};
|
|
237
477
|
const values = database[resource].filter(isObject);
|
|
238
|
-
const
|
|
478
|
+
const inferredSchema = values.length === 0 ? { properties: { id: { type: 'string' } }, type: 'object' } : inferObjectSchema(values);
|
|
479
|
+
const rawSchema = applyConfiguredFields(mergeSchemaOverrides(inferredSchema, { properties: isObject(resourceConfig.properties) ? resourceConfig.properties : {} }), resource, resourceConfig);
|
|
239
480
|
const responseSchema = addRelationSchemas(rawSchema, resources, componentNames, resource);
|
|
240
481
|
|
|
241
482
|
schemas[componentName] = responseSchema;
|
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,9 +1,10 @@
|
|
|
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
|
-
import { createHttpError, getResourceNames, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
|
|
7
|
+
import { createHttpError, getResourceNames, isObject, isSafeKey, resolveDatabasePath, validateDatabase } from './utils.js';
|
|
7
8
|
|
|
8
9
|
const CORS_HEADERS = {
|
|
9
10
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
@@ -31,16 +32,59 @@ const getRequestBody = (body) => {
|
|
|
31
32
|
|
|
32
33
|
const findItem = (collection, id) => collection.find((item) => isObject(item) && String(item.id) === id);
|
|
33
34
|
|
|
35
|
+
const readDatabaseFile = async(databasePath) => {
|
|
36
|
+
let source;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
source = await readFile(databasePath, 'utf8');
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error?.code === 'ENOENT') {
|
|
42
|
+
throw new Error(`Файл базы данных не найден: ${databasePath}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = JSON.parse(source);
|
|
49
|
+
|
|
50
|
+
return validateDatabase(data);
|
|
51
|
+
};
|
|
52
|
+
|
|
34
53
|
export async function createServer({ databasePath, logger = true } = {}) {
|
|
35
|
-
const
|
|
54
|
+
const resolvedDatabasePath = resolveDatabasePath(databasePath);
|
|
55
|
+
const initialData = await readDatabaseFile(resolvedDatabasePath);
|
|
56
|
+
const database = await JSONFilePreset(resolvedDatabasePath, initialData);
|
|
36
57
|
const server = Fastify({ logger });
|
|
58
|
+
let databaseWriteQueue = Promise.resolve();
|
|
59
|
+
|
|
60
|
+
const readDatabase = async() => {
|
|
61
|
+
database.data = await readDatabaseFile(resolvedDatabasePath);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const updateDatabase = (update) => {
|
|
65
|
+
const operation = databaseWriteQueue.then(async() => {
|
|
66
|
+
await readDatabase();
|
|
67
|
+
|
|
68
|
+
const result = update();
|
|
69
|
+
|
|
70
|
+
await database.write();
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
databaseWriteQueue = operation.catch(() => undefined);
|
|
76
|
+
|
|
77
|
+
return operation;
|
|
78
|
+
};
|
|
37
79
|
|
|
38
80
|
server.addHook('onRequest', async(_request, reply) => {
|
|
39
81
|
Object.entries(CORS_HEADERS).forEach(([header, value]) => reply.header(header, value));
|
|
40
82
|
});
|
|
41
83
|
|
|
42
|
-
server.addHook('preHandler', async() => {
|
|
43
|
-
|
|
84
|
+
server.addHook('preHandler', async(request) => {
|
|
85
|
+
if (request.method === 'GET') {
|
|
86
|
+
await readDatabase();
|
|
87
|
+
}
|
|
44
88
|
});
|
|
45
89
|
|
|
46
90
|
server.options('/', async(_request, reply) => reply.code(204).send());
|
|
@@ -51,12 +95,16 @@ export async function createServer({ databasePath, logger = true } = {}) {
|
|
|
51
95
|
const collection = getCollection(database, request.params.resource);
|
|
52
96
|
const where = parseWhere(request.query);
|
|
53
97
|
const embedPaths = parseEmbedPaths(request.query._embed);
|
|
54
|
-
const
|
|
98
|
+
const relationIndexes = new Map();
|
|
99
|
+
const embeddedItems = collection.map((item) => embedItem(database, item, request.params.resource, embedPaths, relationIndexes));
|
|
100
|
+
const pagination = parsePagination(request.query);
|
|
101
|
+
|
|
102
|
+
validateWhere(where, embeddedItems);
|
|
103
|
+
|
|
55
104
|
const filteredItems = embeddedItems.filter((item) => matchesWhere(item, where));
|
|
56
105
|
const sortedItems = sortItems(filteredItems, request.query._sort);
|
|
57
|
-
const page = request.query._page == null ? undefined : Number(request.query._page);
|
|
58
106
|
|
|
59
|
-
return
|
|
107
|
+
return paginateItems(sortedItems, pagination.page, pagination.pageSize);
|
|
60
108
|
});
|
|
61
109
|
|
|
62
110
|
server.get('/:resource/:id', async(request) => {
|
|
@@ -70,59 +118,65 @@ export async function createServer({ databasePath, logger = true } = {}) {
|
|
|
70
118
|
});
|
|
71
119
|
|
|
72
120
|
server.post('/:resource', async(request, reply) => {
|
|
73
|
-
const
|
|
74
|
-
|
|
121
|
+
const item = await updateDatabase(() => {
|
|
122
|
+
const collection = getCollection(database, request.params.resource);
|
|
123
|
+
const createdItem = { ...getRequestBody(request.body), id: randomBytes(8).toString('base64url') };
|
|
124
|
+
|
|
125
|
+
collection.push(createdItem);
|
|
75
126
|
|
|
76
|
-
|
|
77
|
-
|
|
127
|
+
return createdItem;
|
|
128
|
+
});
|
|
78
129
|
|
|
79
130
|
return reply.code(201).send(item);
|
|
80
131
|
});
|
|
81
132
|
|
|
82
133
|
server.put('/:resource/:id', async(request) => {
|
|
83
|
-
|
|
84
|
-
|
|
134
|
+
return updateDatabase(() => {
|
|
135
|
+
const collection = getCollection(database, request.params.resource);
|
|
136
|
+
const currentItem = findItem(collection, request.params.id);
|
|
85
137
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
138
|
+
if (currentItem == null) {
|
|
139
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
140
|
+
}
|
|
89
141
|
|
|
90
|
-
|
|
142
|
+
const item = { ...getRequestBody(request.body), id: currentItem.id };
|
|
91
143
|
|
|
92
|
-
|
|
93
|
-
await database.write();
|
|
144
|
+
collection.splice(collection.indexOf(currentItem), 1, item);
|
|
94
145
|
|
|
95
|
-
|
|
146
|
+
return item;
|
|
147
|
+
});
|
|
96
148
|
});
|
|
97
149
|
|
|
98
150
|
server.patch('/:resource/:id', async(request) => {
|
|
99
|
-
|
|
100
|
-
|
|
151
|
+
return updateDatabase(() => {
|
|
152
|
+
const collection = getCollection(database, request.params.resource);
|
|
153
|
+
const currentItem = findItem(collection, request.params.id);
|
|
101
154
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
155
|
+
if (currentItem == null) {
|
|
156
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
157
|
+
}
|
|
105
158
|
|
|
106
|
-
|
|
159
|
+
const item = { ...currentItem, ...getRequestBody(request.body), id: currentItem.id };
|
|
107
160
|
|
|
108
|
-
|
|
109
|
-
await database.write();
|
|
161
|
+
collection.splice(collection.indexOf(currentItem), 1, item);
|
|
110
162
|
|
|
111
|
-
|
|
163
|
+
return item;
|
|
164
|
+
});
|
|
112
165
|
});
|
|
113
166
|
|
|
114
167
|
server.delete('/:resource/:id', async(request) => {
|
|
115
|
-
|
|
116
|
-
|
|
168
|
+
return updateDatabase(() => {
|
|
169
|
+
const collection = getCollection(database, request.params.resource);
|
|
170
|
+
const currentItem = findItem(collection, request.params.id);
|
|
117
171
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
172
|
+
if (currentItem == null) {
|
|
173
|
+
throw createHttpError(404, 'Запись не найдена');
|
|
174
|
+
}
|
|
121
175
|
|
|
122
|
-
|
|
123
|
-
await database.write();
|
|
176
|
+
collection.splice(collection.indexOf(currentItem), 1);
|
|
124
177
|
|
|
125
|
-
|
|
178
|
+
return currentItem;
|
|
179
|
+
});
|
|
126
180
|
});
|
|
127
181
|
|
|
128
182
|
server.setErrorHandler((error, request, reply) => {
|
package/src/utils.js
CHANGED
|
@@ -13,12 +13,26 @@ export const createHttpError = (statusCode, message) => {
|
|
|
13
13
|
return error;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
export const getResourceNames = (data) => Object.
|
|
16
|
+
export const getResourceNames = (data) => Object.keys(data);
|
|
17
17
|
export const isMainModule = (filePath, moduleUrl) => filePath != null && realpathSync(resolve(filePath)) === fileURLToPath(moduleUrl);
|
|
18
18
|
export const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
19
|
export const isSafeKey = (key) => !UNSAFE_KEYS.has(key);
|
|
20
20
|
export const toArray = (value) => (Array.isArray(value) ? value : [value]);
|
|
21
21
|
|
|
22
|
+
export const validateDatabase = (data) => {
|
|
23
|
+
if (!isObject(data)) {
|
|
24
|
+
throw new Error('База данных должна содержать JSON-объект');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const invalidResource = Object.entries(data).find(([, value]) => !Array.isArray(value));
|
|
28
|
+
|
|
29
|
+
if (invalidResource != null) {
|
|
30
|
+
throw new Error(`Ресурс «${invalidResource[0]}» должен содержать JSON-массив`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return data;
|
|
34
|
+
};
|
|
35
|
+
|
|
22
36
|
export const isEqual = (left, right) => {
|
|
23
37
|
if (Object.is(left, right)) {
|
|
24
38
|
return true;
|