@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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/README.ru.md +209 -0
- package/index.js +589 -0
- 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
|
+
}
|