@kollors/deep-json-server 0.2.6 → 0.3.1

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