@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 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. The genre names are real film genres, while `Gangster film` demonstrates a self-referencing subgenre.
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
- Without `_page`, a GET collection returns an array. With `_page`, it returns:
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 the top-level resource record's primary key `id` 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`.
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
- Без `_page` GET-запрос к коллекции возвращает массив. С `_page` возвращается объект:
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
- Генератор определяет ресурсы и типы полей по всем записям базы. По умолчанию все найденные поля необязательные, а первичный ключ `id` корневой записи ресурса всегда обязательный. Остальные обязательные поля перечисляются в `required`; для вложенных полей используются пути через точку, например `actors.userId`. Объект `formats` добавляет форматы OpenAPI, например `date` и `uri`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kollors/deep-json-server",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "JSON mock server with deep filters and recursive relationship embedding",
5
5
  "type": "module",
6
6
  "bin": {
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 inferSchema = (values, path, options) => {
49
- if (values.every(Array.isArray)) {
50
- const items = values.flat();
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
- return { items: items.length === 0 ? {} : inferSchema(items, path, options), type: 'array' };
114
+ schemas.push({ items: items.length === 0 ? {} : inferSchema(items), type: 'array' });
53
115
  }
54
116
 
55
- if (values.every(isObject)) {
56
- return inferObjectSchema(values, path, options);
117
+ if (objects.length > 0) {
118
+ schemas.push(inferObjectSchema(objects));
57
119
  }
58
120
 
59
- const schemas = values.map((value) => {
121
+ values.filter((value) => !Array.isArray(value) && !isObject(value)).forEach((value) => {
60
122
  if (value === null) {
61
- return { type: 'null' };
123
+ schemas.push({ type: 'null' });
124
+ return;
62
125
  }
63
126
 
64
127
  if (typeof value === 'number') {
65
- return { type: Number.isInteger(value) ? 'integer' : 'number' };
128
+ schemas.push({ type: Number.isInteger(value) ? 'integer' : 'number' });
129
+ return;
66
130
  }
67
131
 
68
- const schema = { type: typeof value };
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, path, options) {
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, fieldPath, options)];
143
+ return [key, inferSchema(fieldValues)];
84
144
  }));
85
- const required = keys.filter((key) => {
86
- const fieldPath = path === '' ? key : `${path}.${key}`;
87
145
 
88
- return path === '' && key === 'id' || options.required.has(fieldPath);
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: true, schema: { minimum: 1, type: 'integer' } },
153
- PerPage: { in: 'query', name: '_perPage', required: true, schema: { minimum: 1, type: 'integer' } },
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
- if (!isObject(database) || !isObject(schemaConfig)) {
215
- throw new Error('База данных и её схема должны содержать JSON-объекты');
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 = isObject(schemaConfig.$schema) ? schemaConfig.$schema : {};
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 rawSchema = values.length === 0 ? { additionalProperties: true, type: 'object' } : inferObjectSchema(values, '', options);
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) => isEqual(value, expectedValue));
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 isEqual(field, expectedValue);
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) => isEqual(value, expectedItem)))
51
- : expectedValues.some((expectedItem) => isEqual(field, 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 !isEqual(field, expectedValue);
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 isEqual(field, condition);
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
- return value === 'null' ? null : value;
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
- return FIELD_OPERATORS.has(operator) ? { operator, path } : undefined;
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('.').filter(Boolean);
167
+ const keys = path.split('.');
143
168
 
144
- if (keys.length === 0 || keys.some((key) => !isSafeKey(key))) {
145
- return;
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 findRelatedValue = (database, item, sourceResource, relation, targetResource) => {
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 relatedItems = localRelation.ids.map((id) => targetItems.find((targetItem) => isObject(targetItem) && isEqual(targetItem.id, id))).filter((targetItem) => targetItem != null);
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 nestedSourceResource = resolveResource(database, relation, sourceResource) ?? relation;
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 (Array.isArray(currentValue)) {
84
- return nestedRelations.length === 0 ? item : { ...item, [relation]: currentValue.map((value) => (isObject(value) ? embedPath(database, value, nestedSourceResource, nestedRelations) : value)) };
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
- if (isObject(currentValue)) {
88
- return nestedRelations.length === 0 ? item : { ...item, [relation]: embedPath(database, currentValue, nestedSourceResource, nestedRelations) };
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 { matchesWhere, paginateItems, parseWhere, sortItems } from './query.js';
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 database = await JSONFilePreset(resolveDatabasePath(databasePath), {});
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
- await database.read();
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 embeddedItems = collection.map((item) => embedItem(database, item, request.params.resource, embedPaths));
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 page == null ? sortedItems : paginateItems(sortedItems, page, Number(request.query._perPage));
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 collection = getCollection(database, request.params.resource);
74
- const item = { ...getRequestBody(request.body), id: randomBytes(8).toString('base64url') };
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
- collection.push(item);
77
- await database.write();
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
- const collection = getCollection(database, request.params.resource);
84
- const currentItem = findItem(collection, request.params.id);
134
+ return updateDatabase(() => {
135
+ const collection = getCollection(database, request.params.resource);
136
+ const currentItem = findItem(collection, request.params.id);
85
137
 
86
- if (currentItem == null) {
87
- throw createHttpError(404, 'Запись не найдена');
88
- }
138
+ if (currentItem == null) {
139
+ throw createHttpError(404, 'Запись не найдена');
140
+ }
89
141
 
90
- const item = { ...getRequestBody(request.body), id: request.params.id };
142
+ const item = { ...getRequestBody(request.body), id: currentItem.id };
91
143
 
92
- collection.splice(collection.indexOf(currentItem), 1, item);
93
- await database.write();
144
+ collection.splice(collection.indexOf(currentItem), 1, item);
94
145
 
95
- return item;
146
+ return item;
147
+ });
96
148
  });
97
149
 
98
150
  server.patch('/:resource/:id', async(request) => {
99
- const collection = getCollection(database, request.params.resource);
100
- const currentItem = findItem(collection, request.params.id);
151
+ return updateDatabase(() => {
152
+ const collection = getCollection(database, request.params.resource);
153
+ const currentItem = findItem(collection, request.params.id);
101
154
 
102
- if (currentItem == null) {
103
- throw createHttpError(404, 'Запись не найдена');
104
- }
155
+ if (currentItem == null) {
156
+ throw createHttpError(404, 'Запись не найдена');
157
+ }
105
158
 
106
- const item = { ...currentItem, ...getRequestBody(request.body), id: request.params.id };
159
+ const item = { ...currentItem, ...getRequestBody(request.body), id: currentItem.id };
107
160
 
108
- collection.splice(collection.indexOf(currentItem), 1, item);
109
- await database.write();
161
+ collection.splice(collection.indexOf(currentItem), 1, item);
110
162
 
111
- return item;
163
+ return item;
164
+ });
112
165
  });
113
166
 
114
167
  server.delete('/:resource/:id', async(request) => {
115
- const collection = getCollection(database, request.params.resource);
116
- const currentItem = findItem(collection, request.params.id);
168
+ return updateDatabase(() => {
169
+ const collection = getCollection(database, request.params.resource);
170
+ const currentItem = findItem(collection, request.params.id);
117
171
 
118
- if (currentItem == null) {
119
- throw createHttpError(404, 'Запись не найдена');
120
- }
172
+ if (currentItem == null) {
173
+ throw createHttpError(404, 'Запись не найдена');
174
+ }
121
175
 
122
- collection.splice(collection.indexOf(currentItem), 1);
123
- await database.write();
176
+ collection.splice(collection.indexOf(currentItem), 1);
124
177
 
125
- return currentItem;
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.entries(data).filter(([, value]) => Array.isArray(value)).map(([resource]) => resource);
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;