@kollors/deep-json-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/README.ru.md +209 -0
  4. package/index.js +589 -0
  5. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kollors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # Deep JSON Server
2
+
3
+ [English](README.md) | [Русский](README.ru.md)
4
+
5
+ A small JSON REST mock server with CRUD, pagination, deep filters and recursive relationship embedding. It keeps the database in one readable JSON file and infers soft relations from conventional keys such as `countryId`, `genreIds` and `publisherIds`.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install --save-dev @kollors/deep-json-server
11
+ ```
12
+
13
+ Add a script to `package.json`:
14
+
15
+ ```json
16
+ {
17
+ "scripts": {
18
+ "mock": "deep-json-server mock/database.json --port 4001"
19
+ }
20
+ }
21
+ ```
22
+
23
+ Then run:
24
+
25
+ ```bash
26
+ npm run mock
27
+ ```
28
+
29
+ The default address is `http://127.0.0.1:4001`. You can also pass `--host` and `--port`, or set the `HOST` and `PORT` environment variables.
30
+
31
+ ## Example database
32
+
33
+ This example is based on a movie catalog. It intentionally has no clothes resource. The genre names are real film genres, while `Gangster film` demonstrates a self-referencing subgenre.
34
+
35
+ ```json
36
+ {
37
+ "countries": [
38
+ { "id": "1", "isArchived": false, "name": "Russia" },
39
+ { "id": "2", "isArchived": false, "name": "United States" }
40
+ ],
41
+ "genres": [
42
+ { "id": "1", "isArchived": false, "name": "Crime", "parentIds": [] },
43
+ { "id": "2", "isArchived": false, "name": "Gangster film", "parentIds": ["1"] },
44
+ { "id": "3", "isArchived": false, "name": "Drama", "parentIds": [] },
45
+ { "id": "4", "isArchived": false, "name": "Comedy", "parentIds": [] }
46
+ ],
47
+ "movies": [
48
+ {
49
+ "actors": [
50
+ { "genreIds": ["2", "3"], "id": "movie-1-actor-1", "userId": "1" },
51
+ { "genreIds": ["3"], "id": "movie-1-actor-2", "userId": "2" }
52
+ ],
53
+ "coverSrc": "https://image.tmdb.org/t/p/w500/3bhkrj58Vtu7enYsRolD1fZdja1.jpg",
54
+ "description": "The story of the Corleone family and the transfer of power from one generation to the next.",
55
+ "id": "1",
56
+ "isArchived": false,
57
+ "publisherIds": ["2"],
58
+ "title": "The Godfather"
59
+ },
60
+ {
61
+ "actors": [],
62
+ "coverSrc": "https://image.tmdb.org/t/p/w500/eWdyYQreja6JGCzqHWXpWHDrrPo.jpg",
63
+ "description": "The adventures of a concierge and his young assistant in a famous European hotel.",
64
+ "id": "2",
65
+ "isArchived": false,
66
+ "publisherIds": ["1"],
67
+ "title": "The Grand Budapest Hotel"
68
+ }
69
+ ],
70
+ "publishers": [
71
+ { "id": "1", "isArchived": false, "name": "A24" },
72
+ { "id": "2", "isArchived": false, "name": "Paramount Pictures" }
73
+ ],
74
+ "users": [
75
+ {
76
+ "bornAt": "1989-01-25",
77
+ "countryId": "1",
78
+ "fullName": "Alexander Petrov",
79
+ "id": "1",
80
+ "isArchived": false
81
+ },
82
+ {
83
+ "bornAt": "1984-09-05",
84
+ "countryId": "1",
85
+ "fullName": "Yulia Peresild",
86
+ "id": "2",
87
+ "isArchived": false
88
+ }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ Every top-level array becomes a REST resource:
94
+
95
+ ```text
96
+ GET /movies
97
+ GET /movies/:id
98
+ POST /movies
99
+ PUT /movies/:id
100
+ PATCH /movies/:id
101
+ DELETE /movies/:id
102
+ ```
103
+
104
+ `POST` generates a string ID. `PUT`, `PATCH` and `DELETE` persist their changes in the JSON file.
105
+
106
+ ## Pagination and sorting
107
+
108
+ ```http
109
+ GET /movies?_page=1&_per_page=10&_sort=-id,title
110
+ ```
111
+
112
+ Without `_page`, a GET collection returns an array. With `_page`, it returns:
113
+
114
+ ```json
115
+ {
116
+ "data": [],
117
+ "first": 1,
118
+ "items": 0,
119
+ "last": 1,
120
+ "next": null,
121
+ "pages": 1,
122
+ "prev": null
123
+ }
124
+ ```
125
+
126
+ Prefix a sort field with `-` for descending order.
127
+
128
+ ## Filters
129
+
130
+ Pass a JSON object through `_where`:
131
+
132
+ ```http
133
+ GET /movies?_where={"title":{"contains":"father"}}
134
+ ```
135
+
136
+ Nested objects and arrays can be filtered at any depth. Conditions in one object use `AND` by default:
137
+
138
+ ```json
139
+ {
140
+ "actors": { "some": { "userId": { "eq": "1" } } },
141
+ "title": { "contains": "father" }
142
+ }
143
+ ```
144
+
145
+ Logical operators are also available:
146
+
147
+ ```json
148
+ {
149
+ "or": [
150
+ { "title": { "contains": "father" } },
151
+ { "actors": { "some": { "userId": { "eq": "2" } } } }
152
+ ]
153
+ }
154
+ ```
155
+
156
+ Supported field operators: `contains`, `endsWith`, `eq`, `every`, `gt`, `gte`, `in`, `lt`, `lte`, `ne`, `none`, `not`, `some` and `startsWith`.
157
+
158
+ Simple query parameters are supported too:
159
+
160
+ ```http
161
+ GET /movies?title:contains=father
162
+ ```
163
+
164
+ ## Relationships
165
+
166
+ Use `_embed` to replace IDs with related records:
167
+
168
+ ```http
169
+ GET /movies/1?_embed=actors.user.country&_embed=actors.genres&_embed=publishers
170
+ ```
171
+
172
+ The response contains actors, each actor's user and genres, the user's country, and publishers. Embedding can follow any number of levels:
173
+
174
+ ```http
175
+ GET /movies/1?_embed=actors.user.country
176
+ GET /genres/2?_embed=parents.parents
177
+ ```
178
+
179
+ Reverse relationships work as well:
180
+
181
+ ```http
182
+ GET /countries/1?_embed=users
183
+ ```
184
+
185
+ Relations are inferred by convention:
186
+
187
+ - `countryId` points to `countries`;
188
+ - `userId` points to `users` when the requested relation is `user`;
189
+ - `genreIds` points to `genres`;
190
+ - `publisherIds` points to `publishers`;
191
+ - `parentIds` points back to the current resource when `_embed=parents` is requested.
192
+
193
+ They are soft references: the server resolves them when requested but does not enforce referential integrity when data is written.
194
+
195
+ ## Programmatic API
196
+
197
+ ```js
198
+ import { createServer, startServer } from '@kollors/deep-json-server';
199
+
200
+ const server = await createServer({ databasePath: 'mock/database.json', logger: false });
201
+
202
+ const response = await server.inject({ method: 'GET', url: '/movies' });
203
+
204
+ await server.close();
205
+
206
+ await startServer({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001 });
207
+ ```
208
+
209
+ `createServer()` is useful for tests because it returns a Fastify instance without opening a network port.
package/README.ru.md ADDED
@@ -0,0 +1,209 @@
1
+ # Deep JSON Server
2
+
3
+ [Русский](README.ru.md) | [English](README.md)
4
+
5
+ Небольшой JSON REST mock-сервер с CRUD, пагинацией, глубокой фильтрацией и рекурсивной загрузкой связей. База данных хранится в одном читаемом JSON-файле, а мягкие связи определяются по соглашениям о нейминге ключей: `countryId`, `genreIds`, `publisherIds` и так далее.
6
+
7
+ ## Установка
8
+
9
+ ```bash
10
+ npm install --save-dev @kollors/deep-json-server
11
+ ```
12
+
13
+ Добавьте команду в `package.json`:
14
+
15
+ ```json
16
+ {
17
+ "scripts": {
18
+ "mock": "deep-json-server mock/database.json --port 4001"
19
+ }
20
+ }
21
+ ```
22
+
23
+ Запустите сервер:
24
+
25
+ ```bash
26
+ npm run mock
27
+ ```
28
+
29
+ По умолчанию сервер доступен по адресу `http://127.0.0.1:4001`. Адрес и порт можно задать через `--host` и `--port` либо переменные окружения `HOST` и `PORT`.
30
+
31
+ ## Пример базы данных
32
+
33
+ Пример основан на каталоге фильмов. В нём намеренно нет сущности одежды. Используются реальные жанры фильмов, а `Гангстерский фильм` демонстрирует связь с родительским жанром.
34
+
35
+ ```json
36
+ {
37
+ "countries": [
38
+ { "id": "1", "isArchived": false, "name": "Россия" },
39
+ { "id": "2", "isArchived": false, "name": "США" }
40
+ ],
41
+ "genres": [
42
+ { "id": "1", "isArchived": false, "name": "Криминал", "parentIds": [] },
43
+ { "id": "2", "isArchived": false, "name": "Гангстерский фильм", "parentIds": ["1"] },
44
+ { "id": "3", "isArchived": false, "name": "Драма", "parentIds": [] },
45
+ { "id": "4", "isArchived": false, "name": "Комедия", "parentIds": [] }
46
+ ],
47
+ "movies": [
48
+ {
49
+ "actors": [
50
+ { "genreIds": ["2", "3"], "id": "movie-1-actor-1", "userId": "1" },
51
+ { "genreIds": ["3"], "id": "movie-1-actor-2", "userId": "2" }
52
+ ],
53
+ "coverSrc": "https://image.tmdb.org/t/p/w500/3bhkrj58Vtu7enYsRolD1fZdja1.jpg",
54
+ "description": "История семьи Корлеоне и передачи власти от одного поколения другому.",
55
+ "id": "1",
56
+ "isArchived": false,
57
+ "publisherIds": ["2"],
58
+ "title": "Крёстный отец"
59
+ },
60
+ {
61
+ "actors": [],
62
+ "coverSrc": "https://image.tmdb.org/t/p/w500/eWdyYQreja6JGCzqHWXpWHDrrPo.jpg",
63
+ "description": "Приключения консьержа и его юного помощника в знаменитом европейском отеле.",
64
+ "id": "2",
65
+ "isArchived": false,
66
+ "publisherIds": ["1"],
67
+ "title": "Отель «Гранд Будапешт»"
68
+ }
69
+ ],
70
+ "publishers": [
71
+ { "id": "1", "isArchived": false, "name": "A24" },
72
+ { "id": "2", "isArchived": false, "name": "Paramount Pictures" }
73
+ ],
74
+ "users": [
75
+ {
76
+ "bornAt": "1989-01-25",
77
+ "countryId": "1",
78
+ "fullName": "Александр Петров",
79
+ "id": "1",
80
+ "isArchived": false
81
+ },
82
+ {
83
+ "bornAt": "1984-09-05",
84
+ "countryId": "1",
85
+ "fullName": "Юлия Пересильд",
86
+ "id": "2",
87
+ "isArchived": false
88
+ }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ Каждый массив верхнего уровня становится REST-ресурсом:
94
+
95
+ ```text
96
+ GET /movies
97
+ GET /movies/:id
98
+ POST /movies
99
+ PUT /movies/:id
100
+ PATCH /movies/:id
101
+ DELETE /movies/:id
102
+ ```
103
+
104
+ `POST` генерирует строковый ID. `PUT`, `PATCH` и `DELETE` сохраняют изменения в JSON-файле.
105
+
106
+ ## Пагинация и сортировка
107
+
108
+ ```http
109
+ GET /movies?_page=1&_per_page=10&_sort=-id,title
110
+ ```
111
+
112
+ Без `_page` GET-запрос к коллекции возвращает массив. С `_page` возвращается объект:
113
+
114
+ ```json
115
+ {
116
+ "data": [],
117
+ "first": 1,
118
+ "items": 0,
119
+ "last": 1,
120
+ "next": null,
121
+ "pages": 1,
122
+ "prev": null
123
+ }
124
+ ```
125
+
126
+ Префикс `-` перед полем включает сортировку по убыванию.
127
+
128
+ ## Фильтры
129
+
130
+ Передайте JSON-объект через `_where`:
131
+
132
+ ```http
133
+ GET /movies?_where={"title":{"contains":"отец"}}
134
+ ```
135
+
136
+ Можно фильтровать вложенные объекты и массивы на любой глубине. Условия внутри одного объекта по умолчанию объединяются через `AND`:
137
+
138
+ ```json
139
+ {
140
+ "actors": { "some": { "userId": { "eq": "1" } } },
141
+ "title": { "contains": "отец" }
142
+ }
143
+ ```
144
+
145
+ Доступны логические операторы:
146
+
147
+ ```json
148
+ {
149
+ "or": [
150
+ { "title": { "contains": "отец" } },
151
+ { "actors": { "some": { "userId": { "eq": "2" } } } }
152
+ ]
153
+ }
154
+ ```
155
+
156
+ Поддерживаются операторы полей: `contains`, `endsWith`, `eq`, `every`, `gt`, `gte`, `in`, `lt`, `lte`, `ne`, `none`, `not`, `some` и `startsWith`.
157
+
158
+ Также можно использовать простые query-параметры:
159
+
160
+ ```http
161
+ GET /movies?title:contains=отец
162
+ ```
163
+
164
+ ## Связи
165
+
166
+ Используйте `_embed`, чтобы заменить ID связанными записями:
167
+
168
+ ```http
169
+ GET /movies/1?_embed=actors.user.country&_embed=actors.genres&_embed=publishers
170
+ ```
171
+
172
+ Ответ будет содержать актёров, пользователя и жанры каждого актёра, страну пользователя и издателей. Глубина вложения не ограничена:
173
+
174
+ ```http
175
+ GET /movies/1?_embed=actors.user.country
176
+ GET /genres/2?_embed=parents.parents
177
+ ```
178
+
179
+ Поддерживаются и обратные связи:
180
+
181
+ ```http
182
+ GET /countries/1?_embed=users
183
+ ```
184
+
185
+ Связи определяются по неймингу:
186
+
187
+ - `countryId` ссылается на `countries`;
188
+ - `userId` ссылается на `users`, если запрошена связь `user`;
189
+ - `genreIds` ссылается на `genres`;
190
+ - `publisherIds` ссылается на `publishers`;
191
+ - `parentIds` ссылается на тот же ресурс, если запрошена связь `_embed=parents`.
192
+
193
+ Это мягкие ссылки: сервер загружает их по запросу, но не проверяет ссылочную целостность при записи данных.
194
+
195
+ ## Программный API
196
+
197
+ ```js
198
+ import { createServer, startServer } from '@kollors/deep-json-server';
199
+
200
+ const server = await createServer({ databasePath: 'mock/database.json', logger: false });
201
+
202
+ const response = await server.inject({ method: 'GET', url: '/movies' });
203
+
204
+ await server.close();
205
+
206
+ await startServer({ databasePath: 'mock/database.json', host: '127.0.0.1', port: 4001 });
207
+ ```
208
+
209
+ `createServer()` удобен для тестов: он возвращает экземпляр Fastify, не открывая сетевой порт.
package/index.js ADDED
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env node
2
+
3
+ import Fastify from 'fastify';
4
+ import { JSONFilePreset } from 'lowdb/node';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { resolve } from 'node:path';
7
+ import process from 'node:process';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const FIELD_OPERATORS = new Set(['contains', 'endsWith', 'eq', 'every', 'gt', 'gte', 'in', 'lt', 'lte', 'ne', 'none', 'not', 'some', 'startsWith']);
11
+ const RESERVED_QUERY_KEYS = new Set(['_embed', '_page', '_per_page', '_sort', '_where']);
12
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
13
+ const CORS_HEADERS = {
14
+ 'Access-Control-Allow-Headers': 'Content-Type',
15
+ 'Access-Control-Allow-Methods': 'DELETE, GET, OPTIONS, PATCH, POST, PUT',
16
+ 'Access-Control-Allow-Origin': '*',
17
+ };
18
+
19
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
20
+ const isSafeKey = (key) => !UNSAFE_KEYS.has(key);
21
+ const toArray = (value) => (Array.isArray(value) ? value : [value]);
22
+
23
+ const createHttpError = (statusCode, message) => {
24
+ const error = new Error(message);
25
+
26
+ error.statusCode = statusCode;
27
+
28
+ return error;
29
+ };
30
+
31
+ const isEqual = (left, right) => {
32
+ if (Object.is(left, right)) {
33
+ return true;
34
+ }
35
+
36
+ if (Array.isArray(left) && Array.isArray(right)) {
37
+ return left.length === right.length && left.every((value, index) => isEqual(value, right[index]));
38
+ }
39
+
40
+ if (isObject(left) && isObject(right)) {
41
+ const leftKeys = Object.keys(left);
42
+ const rightKeys = Object.keys(right);
43
+
44
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => isSafeKey(key) && Object.hasOwn(right, key) && isEqual(left[key], right[key]));
45
+ }
46
+
47
+ return false;
48
+ };
49
+
50
+ const compareValues = (left, right) => {
51
+ if (Object.is(left, right)) {
52
+ return 0;
53
+ }
54
+
55
+ if (left == null) {
56
+ return 1;
57
+ }
58
+
59
+ if (right == null) {
60
+ return -1;
61
+ }
62
+
63
+ if (typeof left === 'number' && typeof right === 'number') {
64
+ return left - right;
65
+ }
66
+
67
+ return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
68
+ };
69
+
70
+ const getValueByPath = (value, path) => path.split('.').reduce((currentValue, key) => {
71
+ return isSafeKey(key) && currentValue != null ? currentValue[key] : undefined;
72
+ }, value);
73
+
74
+ const matchesOperator = (field, operator, expectedValue) => {
75
+ switch (operator) {
76
+ case 'contains':
77
+ return typeof field === 'string'
78
+ ? field.toLowerCase().includes(String(expectedValue).toLowerCase())
79
+ : Array.isArray(field) && field.some((value) => isEqual(value, expectedValue));
80
+ case 'endsWith':
81
+ return typeof field === 'string' && field.toLowerCase().endsWith(String(expectedValue).toLowerCase());
82
+ case 'eq':
83
+ return isEqual(field, expectedValue);
84
+ case 'every':
85
+ return Array.isArray(field) && field.every((value) => matchesValue(value, expectedValue));
86
+ case 'gt':
87
+ return field != null && field > expectedValue;
88
+ case 'gte':
89
+ return field != null && field >= expectedValue;
90
+ case 'in': {
91
+ const expectedValues = toArray(expectedValue);
92
+
93
+ return Array.isArray(field)
94
+ ? field.some((value) => expectedValues.some((expectedItem) => isEqual(value, expectedItem)))
95
+ : expectedValues.some((expectedItem) => isEqual(field, expectedItem));
96
+ }
97
+ case 'lt':
98
+ return field != null && field < expectedValue;
99
+ case 'lte':
100
+ return field != null && field <= expectedValue;
101
+ case 'ne':
102
+ return !isEqual(field, expectedValue);
103
+ case 'none':
104
+ return Array.isArray(field) && !field.some((value) => matchesValue(value, expectedValue));
105
+ case 'not':
106
+ return !matchesValue(field, expectedValue);
107
+ case 'some':
108
+ return Array.isArray(field) && field.some((value) => matchesValue(value, expectedValue));
109
+ case 'startsWith':
110
+ return typeof field === 'string' && field.toLowerCase().startsWith(String(expectedValue).toLowerCase());
111
+ default:
112
+ return false;
113
+ }
114
+ };
115
+
116
+ function matchesValue(field, condition) {
117
+ if (!isObject(condition)) {
118
+ return isEqual(field, condition);
119
+ }
120
+
121
+ const conditionEntries = Object.entries(condition);
122
+ const operatorEntries = conditionEntries.filter(([operator]) => FIELD_OPERATORS.has(operator));
123
+ const nestedEntries = conditionEntries.filter(([key]) => !FIELD_OPERATORS.has(key));
124
+
125
+ if (!operatorEntries.every(([operator, expectedValue]) => matchesOperator(field, operator, expectedValue))) {
126
+ return false;
127
+ }
128
+
129
+ if (nestedEntries.length === 0) {
130
+ return true;
131
+ }
132
+
133
+ return isObject(field) && matchesWhere(field, Object.fromEntries(nestedEntries));
134
+ }
135
+
136
+ function matchesWhere(value, where) {
137
+ if (!isObject(value) || !isObject(where)) {
138
+ return false;
139
+ }
140
+
141
+ return Object.entries(where).every(([key, condition]) => {
142
+ if (key === 'and') {
143
+ return Array.isArray(condition) && condition.every((nestedWhere) => matchesWhere(value, nestedWhere));
144
+ }
145
+
146
+ if (key === 'or') {
147
+ return Array.isArray(condition) && condition.length > 0 && condition.some((nestedWhere) => matchesWhere(value, nestedWhere));
148
+ }
149
+
150
+ if (key === 'not') {
151
+ return isObject(condition) && !matchesWhere(value, condition);
152
+ }
153
+
154
+ return isSafeKey(key) && matchesValue(value[key], condition);
155
+ });
156
+ }
157
+
158
+ const parsePrimitive = (value) => {
159
+ if (value === 'true') {
160
+ return true;
161
+ }
162
+
163
+ if (value === 'false') {
164
+ return false;
165
+ }
166
+
167
+ if (value === 'null') {
168
+ return null;
169
+ }
170
+
171
+ return value;
172
+ };
173
+
174
+ const parseFilterKey = (key) => {
175
+ const colonIndex = key.lastIndexOf(':');
176
+
177
+ if (colonIndex !== -1) {
178
+ const path = key.slice(0, colonIndex);
179
+ const operator = key.slice(colonIndex + 1);
180
+
181
+ return FIELD_OPERATORS.has(operator) ? { operator, path } : undefined;
182
+ }
183
+
184
+ const legacyOperator = key.match(/^(.*)_([a-zA-Z]+)$/);
185
+
186
+ if (legacyOperator?.[1] != null && legacyOperator[2] != null && FIELD_OPERATORS.has(legacyOperator[2])) {
187
+ return { operator: legacyOperator[2], path: legacyOperator[1] };
188
+ }
189
+
190
+ return { operator: 'eq', path: key };
191
+ };
192
+
193
+ const setWhereOperator = (where, path, operator, value) => {
194
+ const keys = path.split('.').filter(Boolean);
195
+
196
+ if (keys.length === 0 || keys.some((key) => !isSafeKey(key))) {
197
+ return;
198
+ }
199
+
200
+ const fieldKey = keys.pop();
201
+ let currentValue = where;
202
+
203
+ keys.forEach((key) => {
204
+ currentValue[key] = isObject(currentValue[key]) ? currentValue[key] : {};
205
+ currentValue = currentValue[key];
206
+ });
207
+
208
+ currentValue[fieldKey] = isObject(currentValue[fieldKey]) ? currentValue[fieldKey] : {};
209
+ currentValue[fieldKey][operator] = operator === 'in' && typeof value === 'string' ? value.split(',').map((item) => parsePrimitive(item.trim())) : parsePrimitive(value);
210
+ };
211
+
212
+ const parseWhere = (query) => {
213
+ const rawWhere = toArray(query._where).at(-1);
214
+
215
+ if (rawWhere != null) {
216
+ try {
217
+ const where = JSON.parse(rawWhere);
218
+
219
+ if (!isObject(where)) {
220
+ throw new Error();
221
+ }
222
+
223
+ return where;
224
+ } catch {
225
+ throw createHttpError(400, 'Параметр _where должен содержать JSON-объект');
226
+ }
227
+ }
228
+
229
+ const where = {};
230
+
231
+ Object.entries(query).forEach(([key, rawValue]) => {
232
+ if (RESERVED_QUERY_KEYS.has(key)) {
233
+ return;
234
+ }
235
+
236
+ const filterKey = parseFilterKey(key);
237
+
238
+ if (filterKey == null) {
239
+ return;
240
+ }
241
+
242
+ toArray(rawValue).forEach((value) => setWhereOperator(where, filterKey.path, filterKey.operator, value));
243
+ });
244
+
245
+ return where;
246
+ };
247
+
248
+ const singularize = (value) => {
249
+ if (value === 'clothes') {
250
+ return value;
251
+ }
252
+
253
+ if (value.endsWith('ies')) {
254
+ return `${value.slice(0, -3)}y`;
255
+ }
256
+
257
+ return value.endsWith('s') ? value.slice(0, -1) : value;
258
+ };
259
+
260
+ const getResourceNames = (database) => Object.entries(database.data).filter(([, value]) => Array.isArray(value)).map(([resource]) => resource);
261
+
262
+ const resolveResource = (database, relation, sourceResource) => {
263
+ const resourceNames = getResourceNames(database);
264
+ const resource = resourceNames.find((resourceName) => resourceName === relation) ?? resourceNames.find((resourceName) => singularize(resourceName) === relation);
265
+
266
+ if (resource != null) {
267
+ return resource;
268
+ }
269
+
270
+ return ['child', 'children', 'parent', 'parents'].includes(relation) && resourceNames.includes(sourceResource) ? sourceResource : undefined;
271
+ };
272
+
273
+ const getRelationKeys = (...names) => [...new Set(names.flatMap((name) => [`${name}Id`, `${name}Ids`]))];
274
+
275
+ const findLocalRelation = (item, relation, targetResource) => {
276
+ const relationKey = getRelationKeys(relation, singularize(relation), targetResource, singularize(targetResource)).find((key) => Object.hasOwn(item, key));
277
+
278
+ if (relationKey == null) {
279
+ return undefined;
280
+ }
281
+
282
+ return { ids: toArray(item[relationKey]).filter((id) => id != null), isMany: relationKey.endsWith('Ids') || Array.isArray(item[relationKey]) };
283
+ };
284
+
285
+ const hasReference = (value, relationKeys, id) => {
286
+ if (Array.isArray(value)) {
287
+ return value.some((item) => hasReference(item, relationKeys, id));
288
+ }
289
+
290
+ if (!isObject(value)) {
291
+ return false;
292
+ }
293
+
294
+ return Object.entries(value).some(([key, nestedValue]) => {
295
+ if (!isSafeKey(key)) {
296
+ return false;
297
+ }
298
+
299
+ if (relationKeys.includes(key)) {
300
+ return toArray(nestedValue).some((nestedId) => isEqual(nestedId, id));
301
+ }
302
+
303
+ return hasReference(nestedValue, relationKeys, id);
304
+ });
305
+ };
306
+
307
+ const findRelatedValue = (database, item, sourceResource, relation, targetResource) => {
308
+ const targetItems = database.data[targetResource];
309
+ const localRelation = findLocalRelation(item, relation, targetResource);
310
+
311
+ if (!Array.isArray(targetItems)) {
312
+ return undefined;
313
+ }
314
+
315
+ if (localRelation != null) {
316
+ const relatedItems = localRelation.ids.map((id) => targetItems.find((targetItem) => isObject(targetItem) && isEqual(targetItem.id, id))).filter((targetItem) => targetItem != null);
317
+
318
+ return localRelation.isMany ? relatedItems : relatedItems[0] ?? null;
319
+ }
320
+
321
+ if (item.id == null) {
322
+ return undefined;
323
+ }
324
+
325
+ const reverseRelationKeys = getRelationKeys(singularize(sourceResource));
326
+
327
+ if (relation === 'child' || relation === 'children') {
328
+ reverseRelationKeys.push('parentId', 'parentIds');
329
+ }
330
+
331
+ return targetItems.filter((targetItem) => hasReference(targetItem, reverseRelationKeys, item.id));
332
+ };
333
+
334
+ const embedPath = (database, item, sourceResource, [relation, ...nestedRelations]) => {
335
+ if (relation == null || !isSafeKey(relation)) {
336
+ return item;
337
+ }
338
+
339
+ const currentValue = item[relation];
340
+ const nestedSourceResource = resolveResource(database, relation, sourceResource) ?? relation;
341
+
342
+ if (Array.isArray(currentValue)) {
343
+ return nestedRelations.length === 0 ? item : { ...item, [relation]: currentValue.map((value) => (isObject(value) ? embedPath(database, value, nestedSourceResource, nestedRelations) : value)) };
344
+ }
345
+
346
+ if (isObject(currentValue)) {
347
+ return nestedRelations.length === 0 ? item : { ...item, [relation]: embedPath(database, currentValue, nestedSourceResource, nestedRelations) };
348
+ }
349
+
350
+ const targetResource = resolveResource(database, relation, sourceResource);
351
+
352
+ if (targetResource == null) {
353
+ return item;
354
+ }
355
+
356
+ const relatedValue = findRelatedValue(database, item, sourceResource, relation, targetResource);
357
+
358
+ if (relatedValue == null || nestedRelations.length === 0) {
359
+ return relatedValue === undefined ? item : { ...item, [relation]: relatedValue };
360
+ }
361
+
362
+ return {
363
+ ...item,
364
+ [relation]: Array.isArray(relatedValue)
365
+ ? relatedValue.map((value) => embedPath(database, value, targetResource, nestedRelations))
366
+ : embedPath(database, relatedValue, targetResource, nestedRelations),
367
+ };
368
+ };
369
+
370
+ const parseEmbedPaths = (embed) => toArray(embed)
371
+ .flatMap((value) => (typeof value === 'string' ? value.split(',') : []))
372
+ .map((path) => path.split('.').filter(Boolean))
373
+ .filter((path) => path.length > 0 && path.every(isSafeKey));
374
+
375
+ const embedItem = (database, item, resource, embedPaths) => embedPaths.reduce((embeddedItem, path) => embedPath(database, embeddedItem, resource, path), item);
376
+
377
+ const sortItems = (items, sort) => {
378
+ const sortRules = typeof sort === 'string' ? sort.split(',').filter(Boolean) : [];
379
+
380
+ if (sortRules.length === 0) {
381
+ return [...items];
382
+ }
383
+
384
+ return [...items].sort((left, right) => {
385
+ for (const sortRule of sortRules) {
386
+ const isDescending = sortRule.startsWith('-');
387
+ const path = isDescending ? sortRule.slice(1) : sortRule;
388
+ const comparison = compareValues(getValueByPath(left, path), getValueByPath(right, path));
389
+
390
+ if (comparison !== 0) {
391
+ return isDescending ? -comparison : comparison;
392
+ }
393
+ }
394
+
395
+ return 0;
396
+ });
397
+ };
398
+
399
+ const paginateItems = (items, page, pageSize) => {
400
+ const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : 10;
401
+ const pages = Math.max(1, Math.ceil(items.length / safePageSize));
402
+ const safePage = Math.max(1, Math.min(Number.isFinite(page) ? Math.floor(page) : 1, pages));
403
+ const offset = (safePage - 1) * safePageSize;
404
+
405
+ return {
406
+ data: items.slice(offset, offset + safePageSize),
407
+ first: 1,
408
+ items: items.length,
409
+ last: pages,
410
+ next: safePage < pages ? safePage + 1 : null,
411
+ pages,
412
+ prev: safePage > 1 ? safePage - 1 : null,
413
+ };
414
+ };
415
+
416
+ const getCollection = (database, resource) => {
417
+ const collection = isSafeKey(resource) ? database.data[resource] : undefined;
418
+
419
+ if (!Array.isArray(collection)) {
420
+ throw createHttpError(404, 'Ресурс не найден');
421
+ }
422
+
423
+ return collection;
424
+ };
425
+
426
+ const getRequestBody = (body) => {
427
+ if (!isObject(body)) {
428
+ throw createHttpError(400, 'Тело запроса должно быть JSON-объектом');
429
+ }
430
+
431
+ return body;
432
+ };
433
+
434
+ const findItem = (collection, id) => collection.find((item) => isObject(item) && String(item.id) === id);
435
+ const resolveDatabasePath = (databasePath) => {
436
+ if (typeof databasePath !== 'string' || databasePath === '') {
437
+ throw new Error('Укажите путь к JSON-базе данных');
438
+ }
439
+
440
+ return resolve(databasePath);
441
+ };
442
+
443
+ export async function createServer({ databasePath, logger = true } = {}) {
444
+ const database = await JSONFilePreset(resolveDatabasePath(databasePath), {});
445
+ const server = Fastify({ logger });
446
+
447
+ server.addHook('onRequest', async(_request, reply) => {
448
+ Object.entries(CORS_HEADERS).forEach(([header, value]) => reply.header(header, value));
449
+ });
450
+
451
+ server.addHook('preHandler', async() => {
452
+ await database.read();
453
+ });
454
+
455
+ server.options('/', async(_request, reply) => reply.code(204).send());
456
+ server.options('/*', async(_request, reply) => reply.code(204).send());
457
+
458
+ server.get('/', async() => ({ resources: getResourceNames(database) }));
459
+
460
+ server.get('/:resource', async(request) => {
461
+ const collection = getCollection(database, request.params.resource);
462
+ const where = parseWhere(request.query);
463
+ const embedPaths = parseEmbedPaths(request.query._embed);
464
+ const embeddedItems = collection.map((item) => embedItem(database, item, request.params.resource, embedPaths));
465
+ const filteredItems = embeddedItems.filter((item) => matchesWhere(item, where));
466
+ const sortedItems = sortItems(filteredItems, request.query._sort);
467
+ const page = request.query._page == null ? undefined : Number(request.query._page);
468
+
469
+ return page == null ? sortedItems : paginateItems(sortedItems, page, Number(request.query._per_page));
470
+ });
471
+
472
+ server.get('/:resource/:id', async(request) => {
473
+ const item = findItem(getCollection(database, request.params.resource), request.params.id);
474
+
475
+ if (item == null) {
476
+ throw createHttpError(404, 'Запись не найдена');
477
+ }
478
+
479
+ return embedItem(database, item, request.params.resource, parseEmbedPaths(request.query._embed));
480
+ });
481
+
482
+ server.post('/:resource', async(request, reply) => {
483
+ const collection = getCollection(database, request.params.resource);
484
+ const item = { ...getRequestBody(request.body), id: randomBytes(8).toString('base64url') };
485
+
486
+ collection.push(item);
487
+ await database.write();
488
+
489
+ return reply.code(201).send(item);
490
+ });
491
+
492
+ server.put('/:resource/:id', async(request) => {
493
+ const collection = getCollection(database, request.params.resource);
494
+ const currentItem = findItem(collection, request.params.id);
495
+
496
+ if (currentItem == null) {
497
+ throw createHttpError(404, 'Запись не найдена');
498
+ }
499
+
500
+ const item = { ...getRequestBody(request.body), id: request.params.id };
501
+ const itemIndex = collection.indexOf(currentItem);
502
+
503
+ collection.splice(itemIndex, 1, item);
504
+ await database.write();
505
+
506
+ return item;
507
+ });
508
+
509
+ server.patch('/:resource/:id', async(request) => {
510
+ const collection = getCollection(database, request.params.resource);
511
+ const currentItem = findItem(collection, request.params.id);
512
+
513
+ if (currentItem == null) {
514
+ throw createHttpError(404, 'Запись не найдена');
515
+ }
516
+
517
+ const item = { ...currentItem, ...getRequestBody(request.body), id: request.params.id };
518
+ const itemIndex = collection.indexOf(currentItem);
519
+
520
+ collection.splice(itemIndex, 1, item);
521
+ await database.write();
522
+
523
+ return item;
524
+ });
525
+
526
+ server.delete('/:resource/:id', async(request) => {
527
+ const collection = getCollection(database, request.params.resource);
528
+ const currentItem = findItem(collection, request.params.id);
529
+
530
+ if (currentItem == null) {
531
+ throw createHttpError(404, 'Запись не найдена');
532
+ }
533
+
534
+ collection.splice(collection.indexOf(currentItem), 1);
535
+ await database.write();
536
+
537
+ return currentItem;
538
+ });
539
+
540
+ server.setErrorHandler((error, request, reply) => {
541
+ const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
542
+
543
+ if (statusCode === 500) {
544
+ request.log.error(error);
545
+ }
546
+
547
+ return reply.code(statusCode).send({ error: error.message });
548
+ });
549
+
550
+ return server;
551
+ }
552
+
553
+ export async function startServer({ databasePath, host = '127.0.0.1', logger = true, port = 4001 } = {}) {
554
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
555
+ throw new Error('Порт должен быть целым числом от 1 до 65535');
556
+ }
557
+
558
+ const resolvedDatabasePath = resolveDatabasePath(databasePath);
559
+ const server = await createServer({ databasePath: resolvedDatabasePath, logger });
560
+
561
+ await server.listen({ host, port });
562
+ server.log.info({ database: resolvedDatabasePath }, 'Deep JSON Server запущен');
563
+
564
+ return server;
565
+ }
566
+
567
+ const getCliOption = (argumentsList, ...names) => {
568
+ const optionIndex = argumentsList.findIndex((argument) => names.includes(argument));
569
+
570
+ return optionIndex === -1 ? undefined : argumentsList[optionIndex + 1];
571
+ };
572
+
573
+ const runCli = async() => {
574
+ const [databaseArgument, ...cliArguments] = process.argv.slice(2);
575
+
576
+ if (databaseArgument == null || databaseArgument.startsWith('-')) {
577
+ throw new Error('Укажите путь к базе данных: deep-json-server database.json');
578
+ }
579
+
580
+ await startServer({
581
+ databasePath: databaseArgument,
582
+ host: getCliOption(cliArguments, '--host', '-h') ?? process.env.HOST ?? '127.0.0.1',
583
+ port: Number(getCliOption(cliArguments, '--port', '-p') ?? process.env.PORT ?? 4001),
584
+ });
585
+ };
586
+
587
+ if (process.argv[1] != null && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
588
+ await runCli();
589
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@kollors/deep-json-server",
3
+ "version": "0.1.0",
4
+ "description": "JSON mock server with deep filters and recursive relationship embedding",
5
+ "type": "module",
6
+ "bin": {
7
+ "deep-json-server": "index.js"
8
+ },
9
+ "exports": {
10
+ ".": "./index.js"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "LICENSE",
15
+ "README.md",
16
+ "README.ru.md"
17
+ ],
18
+ "scripts": {
19
+ "check": "node --check index.js",
20
+ "test": "node --test"
21
+ },
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "dependencies": {
26
+ "fastify": "^5.12.1",
27
+ "lowdb": "^7.0.1"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/kollors/deep-json-server.git"
32
+ },
33
+ "homepage": "https://github.com/kollors/deep-json-server#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/kollors/deep-json-server/issues"
36
+ },
37
+ "keywords": [
38
+ "api",
39
+ "deep-filter",
40
+ "json",
41
+ "mock",
42
+ "relations",
43
+ "rest"
44
+ ],
45
+ "author": "kollors",
46
+ "license": "MIT",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }