@kollors/deep-json-server 0.6.0 → 0.7.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.
@@ -1,4 +1,4 @@
1
- import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js';
1
+ import { DEFAULT_MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE } from '../constants.js';
2
2
  import { validateDatabase } from '../database.js';
3
3
  import { getRelationMetadata } from '../relation-metadata.js';
4
4
  import { getResourceNames, isObject, singularize, toPascalCase } from '../utils.js';
@@ -85,8 +85,11 @@ const addReverseRelations = (schemas, rawSchemas, resources, componentNames) =>
85
85
  };
86
86
 
87
87
  const createParameters = (maxPageSize) => ({
88
- ContentName: { description: 'URI-encoded relative file name', in: 'header', name: 'Content-Name', required: true, schema: { type: 'string' } },
88
+ ContentDirectory: { description: 'URI-encoded relative storage directory', in: 'header', name: 'Content-Directory', schema: { type: 'string' } },
89
+ ContentName: { description: 'URI-encoded file name', in: 'header', name: 'Content-Name', required: true, schema: { type: 'string' } },
90
+ ContentOverride: { description: 'Overwrite an existing file at the same path', in: 'header', name: 'Content-Override', schema: { default: false, type: 'boolean' } },
89
91
  Embed: { description: 'Relationship paths to embed', explode: true, in: 'query', name: '_embed', schema: { items: { type: 'string' }, type: 'array' }, style: 'form' },
92
+ FilePath: { allowReserved: true, description: 'URI-encoded file path relative to the storage directory', in: 'path', name: 'path', required: true, schema: { type: 'string' } },
90
93
  Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
91
94
  Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
92
95
  PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum: maxPageSize, minimum: 1, type: 'integer' } },
@@ -100,52 +103,110 @@ const createParameterReference = (name) => ({ $ref: `#/components/parameters/${n
100
103
  const createRequestBody = (name) => ({ required: true, ...createJsonContent(createSchemaReference(name)) });
101
104
 
102
105
  const createFilePaths = () => ({
103
- '/_files': {
106
+ '/_files/download/{path}': {
107
+ get: {
108
+ operationId: 'downloadFile',
109
+ parameters: [createParameterReference('FilePath')],
110
+ responses: {
111
+ 200: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, description: 'File download' },
112
+ 400: createResponse('Invalid path', createSchemaReference('Error')),
113
+ 404: createResponse('Not found', createSchemaReference('Error')),
114
+ },
115
+ tags: ['files'],
116
+ },
117
+ },
118
+ '/_files/metadata/{path}': {
119
+ get: {
120
+ operationId: 'getFileMetadata',
121
+ parameters: [createParameterReference('FilePath')],
122
+ responses: {
123
+ 200: createResponse('File metadata', createSchemaReference('FileMetadata')),
124
+ 400: createResponse('Invalid path', createSchemaReference('Error')),
125
+ 404: createResponse('Not found', createSchemaReference('Error')),
126
+ },
127
+ tags: ['files'],
128
+ },
129
+ },
130
+ '/_files/storage': {
104
131
  post: {
105
132
  operationId: 'uploadFile',
106
- parameters: [createParameterReference('ContentName')],
133
+ parameters: ['ContentName', 'ContentDirectory', 'ContentOverride'].map(createParameterReference),
107
134
  requestBody: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, required: true },
108
135
  responses: {
109
- 201: createResponse('Uploaded', createSchemaReference('UploadedFile')),
136
+ 200: createResponse('Overwritten', createSchemaReference('FileMetadata')),
137
+ 201: createResponse('Created', createSchemaReference('FileMetadata')),
110
138
  400: createResponse('Invalid request', createSchemaReference('Error')),
139
+ 409: createResponse('Already exists', createSchemaReference('Error')),
111
140
  413: createResponse('File is too large', createSchemaReference('Error')),
112
141
  415: createResponse('Unsupported media type', createSchemaReference('Error')),
113
142
  },
114
143
  tags: ['files'],
115
144
  },
116
145
  },
117
- '/_files/{id}': {
146
+ '/_files/storage/{path}': {
118
147
  delete: {
119
- operationId: 'deleteFileById',
120
- parameters: [createParameterReference('Id')],
121
- responses: { 200: createResponse('Deleted', createSchemaReference('UploadedFile')), 404: createResponse('Not found', createSchemaReference('Error')) },
148
+ operationId: 'deleteFile',
149
+ parameters: [createParameterReference('FilePath')],
150
+ responses: {
151
+ 204: { description: 'Deleted' },
152
+ 400: createResponse('Invalid path', createSchemaReference('Error')),
153
+ 404: createResponse('Not found', createSchemaReference('Error')),
154
+ },
122
155
  tags: ['files'],
123
156
  },
124
157
  get: {
125
- operationId: 'getFileById',
126
- parameters: [createParameterReference('Id')],
158
+ operationId: 'getFileContent',
159
+ parameters: [createParameterReference('FilePath')],
127
160
  responses: {
128
161
  200: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, description: 'File contents' },
162
+ 400: createResponse('Invalid path', createSchemaReference('Error')),
163
+ 404: createResponse('Not found', createSchemaReference('Error')),
164
+ },
165
+ tags: ['files'],
166
+ },
167
+ patch: {
168
+ operationId: 'updateFile',
169
+ parameters: [createParameterReference('FilePath')],
170
+ requestBody: createRequestBody('FileUpdate'),
171
+ responses: {
172
+ 200: createResponse('Updated', createSchemaReference('FileMetadata')),
173
+ 400: createResponse('Invalid request', createSchemaReference('Error')),
129
174
  404: createResponse('Not found', createSchemaReference('Error')),
175
+ 409: createResponse('Already exists', createSchemaReference('Error')),
176
+ 413: createResponse('Request is too large', createSchemaReference('Error')),
177
+ 415: createResponse('Unsupported media type', createSchemaReference('Error')),
130
178
  },
131
179
  tags: ['files'],
132
180
  },
133
181
  },
134
182
  });
135
183
 
136
- const createResourcePaths = (resource, componentName) => {
184
+ const createResourceOperationIds = (resource) => {
137
185
  const resourceName = toPascalCase(resource);
138
186
 
187
+ return {
188
+ create: `post${resourceName}`,
189
+ get: `get${resourceName}ById`,
190
+ list: `get${resourceName}`,
191
+ remove: `delete${resourceName}ById`,
192
+ replace: `put${resourceName}ById`,
193
+ update: `patch${resourceName}ById`,
194
+ };
195
+ };
196
+
197
+ const createResourcePaths = (resource, componentName) => {
198
+ const operationIds = createResourceOperationIds(resource);
199
+
139
200
  return {
140
201
  [`/${resource}`]: {
141
202
  get: {
142
- operationId: `get${resourceName}`,
203
+ operationId: operationIds.list,
143
204
  parameters: ['Page', 'PerPage', 'Sort', 'Where', 'Embed'].map(createParameterReference),
144
205
  responses: { 200: createResponse('Successful response', createSchemaReference(`${componentName}Page`)), 400: createResponse('Invalid query', createSchemaReference('Error')) },
145
206
  tags: [resource],
146
207
  },
147
208
  post: {
148
- operationId: `post${resourceName}`,
209
+ operationId: operationIds.create,
149
210
  requestBody: createRequestBody(`${componentName}Create`),
150
211
  responses: { 201: createResponse('Created', createSchemaReference(componentName)), 400: createResponse('Invalid request', createSchemaReference('Error')) },
151
212
  tags: [resource],
@@ -153,13 +214,13 @@ const createResourcePaths = (resource, componentName) => {
153
214
  },
154
215
  [`/${resource}/{id}`]: {
155
216
  delete: {
156
- operationId: `delete${resourceName}ById`,
217
+ operationId: operationIds.remove,
157
218
  parameters: [createParameterReference('Id')],
158
219
  responses: { 200: createResponse('Deleted', createSchemaReference(componentName)), 404: createResponse('Not found', createSchemaReference('Error')) },
159
220
  tags: [resource],
160
221
  },
161
222
  get: {
162
- operationId: `get${resourceName}ById`,
223
+ operationId: operationIds.get,
163
224
  parameters: [createParameterReference('Id'), createParameterReference('Embed')],
164
225
  responses: {
165
226
  200: createResponse('Successful response', createSchemaReference(componentName)),
@@ -169,7 +230,7 @@ const createResourcePaths = (resource, componentName) => {
169
230
  tags: [resource],
170
231
  },
171
232
  patch: {
172
- operationId: `patch${resourceName}ById`,
233
+ operationId: operationIds.update,
173
234
  parameters: [createParameterReference('Id')],
174
235
  requestBody: createRequestBody(`${componentName}Update`),
175
236
  responses: {
@@ -180,7 +241,7 @@ const createResourcePaths = (resource, componentName) => {
180
241
  tags: [resource],
181
242
  },
182
243
  put: {
183
- operationId: `put${resourceName}ById`,
244
+ operationId: operationIds.replace,
184
245
  parameters: [createParameterReference('Id')],
185
246
  requestBody: createRequestBody(`${componentName}Create`),
186
247
  responses: {
@@ -194,14 +255,16 @@ const createResourcePaths = (resource, componentName) => {
194
255
  };
195
256
  };
196
257
 
197
- const getOperationIds = (resource) => {
198
- const resourceName = toPascalCase(resource);
199
-
200
- return [`get${resourceName}`, `post${resourceName}`, `delete${resourceName}ById`, `get${resourceName}ById`, `patch${resourceName}ById`, `put${resourceName}ById`];
201
- };
202
-
203
258
  const validateGeneratedNames = (resources, componentNames, files) => {
204
- const schemaOwners = new Map([['Error', 'встроенная схема ошибки'], ...(files ? [['UploadedFile', 'встроенная схема файла']] : [])]);
259
+ const schemaOwners = new Map([
260
+ ['Error', 'встроенная схема ошибки'],
261
+ ...(files
262
+ ? [
263
+ ['FileMetadata', 'встроенная схема метаданных файла'],
264
+ ['FileUpdate', 'встроенная схема изменения файла'],
265
+ ]
266
+ : []),
267
+ ]);
205
268
  const operationOwners = new Map();
206
269
 
207
270
  resources.forEach((resource) => {
@@ -221,7 +284,7 @@ const validateGeneratedNames = (resources, componentNames, files) => {
221
284
  schemaOwners.set(schemaName, resource);
222
285
  });
223
286
 
224
- getOperationIds(resource).forEach((operationId) => {
287
+ Object.values(createResourceOperationIds(resource)).forEach((operationId) => {
225
288
  const owner = operationOwners.get(operationId);
226
289
 
227
290
  if (owner != null) {
@@ -234,12 +297,12 @@ const validateGeneratedNames = (resources, componentNames, files) => {
234
297
  };
235
298
 
236
299
  /**
237
- * Builds an OpenAPI 3.0 document from resolved server data.
238
- * @param {{ database: Record<string, Array<Record<string, unknown>>>, files?: boolean, maxPageSize?: number, schema?: Record<string, unknown> }} options Document options.
300
+ * Builds an OpenAPI document without runtime server addresses.
301
+ * @param {{ database: Record<string, Array<Record<string, unknown>>>, files?: boolean, maxPageSize?: number, schema?: Record<string, unknown> }} options Source data and schema settings.
239
302
  * @returns {Record<string, unknown>} OpenAPI document.
240
303
  */
241
304
  export function buildOpenapiDocument(options) {
242
- const { database, files = false, maxPageSize = MAX_PAGE_SIZE, schema: schemaConfig = {} } = options ?? {};
305
+ const { database, files = false, maxPageSize = DEFAULT_MAX_PAGE_SIZE, schema: schemaConfig = {} } = options ?? {};
243
306
 
244
307
  if (typeof files !== 'boolean') {
245
308
  throw new Error('Ключ files должен содержать boolean');
@@ -280,9 +343,23 @@ export function buildOpenapiDocument(options) {
280
343
  const schemas = {
281
344
  Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
282
345
  ...(files && {
283
- UploadedFile: {
284
- properties: { id: { type: 'string' }, mimeType: { type: 'string' }, name: { type: 'string' }, size: { minimum: 0, type: 'integer' }, url: { type: 'string' } },
285
- required: ['id', 'mimeType', 'name', 'size', 'url'],
346
+ FileMetadata: {
347
+ properties: {
348
+ directory: { type: 'string' },
349
+ downloadUrl: { type: 'string' },
350
+ metadataUrl: { type: 'string' },
351
+ mimeType: { type: 'string' },
352
+ name: { type: 'string' },
353
+ size: { minimum: 0, type: 'integer' },
354
+ url: { type: 'string' },
355
+ },
356
+ required: ['directory', 'downloadUrl', 'metadataUrl', 'mimeType', 'name', 'size', 'url'],
357
+ type: 'object',
358
+ },
359
+ FileUpdate: {
360
+ additionalProperties: false,
361
+ anyOf: [{ required: ['directory'] }, { required: ['name'] }],
362
+ properties: { directory: { type: 'string' }, name: { type: 'string' } },
286
363
  type: 'object',
287
364
  },
288
365
  }),
@@ -10,12 +10,21 @@ const isFilterEqual = (left, right) => {
10
10
  }
11
11
 
12
12
  if (typeof left === 'number' && typeof right === 'string' && NUMBER_PATTERN.test(right)) {
13
+ // Query parameters are strings, so numeric strings must match stored numbers.
13
14
  return left === Number(right);
14
15
  }
15
16
 
16
17
  return typeof right === 'number' && typeof left === 'string' && NUMBER_PATTERN.test(left) && right === Number(left);
17
18
  };
18
19
 
20
+ const validateLogicalConditions = (operator, value) => {
21
+ if (!Array.isArray(value) || (operator === 'or' && value.length === 0) || value.some((condition) => !isObject(condition))) {
22
+ throw createHttpError(400, `Оператор «${operator}» должен содержать ${operator === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
23
+ }
24
+
25
+ return value;
26
+ };
27
+
19
28
  const matchesOperator = (field, operator, expectedValue) => {
20
29
  switch (operator) {
21
30
  case 'contains':
@@ -191,11 +200,7 @@ const validateCondition = (condition, samples, path) => {
191
200
 
192
201
  Object.entries(condition).forEach(([key, value]) => {
193
202
  if (key === 'and' || key === 'or') {
194
- if (!Array.isArray(value) || (key === 'or' && value.length === 0) || value.some((nestedWhere) => !isObject(nestedWhere))) {
195
- throw createHttpError(400, `Оператор «${key}» должен содержать ${key === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
196
- }
197
-
198
- value.forEach((nestedWhere) => {
203
+ validateLogicalConditions(key, value).forEach((nestedWhere) => {
199
204
  validateWhere(nestedWhere, samples, path);
200
205
  });
201
206
  return;
@@ -246,11 +251,7 @@ const validateCondition = (condition, samples, path) => {
246
251
  export const validateWhere = (where, items, path = '') => {
247
252
  Object.entries(where).forEach(([key, condition]) => {
248
253
  if (key === 'and' || key === 'or') {
249
- if (!Array.isArray(condition) || (key === 'or' && condition.length === 0) || condition.some((nestedWhere) => !isObject(nestedWhere))) {
250
- throw createHttpError(400, `Оператор «${key}» должен содержать ${key === 'or' ? 'непустой ' : ''}массив JSON-объектов`);
251
- }
252
-
253
- condition.forEach((nestedWhere) => {
254
+ validateLogicalConditions(key, condition).forEach((nestedWhere) => {
254
255
  validateWhere(nestedWhere, items, path);
255
256
  });
256
257
  return;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js';
1
+ import { DEFAULT_MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE } from '../constants.js';
2
2
  import { createHttpError } from '../utils.js';
3
3
 
4
4
  const parsePositiveInteger = (value, name, defaultValue) => {
@@ -19,7 +19,7 @@ const parsePositiveInteger = (value, name, defaultValue) => {
19
19
  return number;
20
20
  };
21
21
 
22
- export const parsePagination = (query, maxPageSize = MAX_PAGE_SIZE) => {
22
+ export const parsePagination = (query, maxPageSize = DEFAULT_MAX_PAGE_SIZE) => {
23
23
  const page = parsePositiveInteger(query._page, '_page', 1);
24
24
  const pageSize = parsePositiveInteger(query._perPage, '_perPage', DEFAULT_PAGE_SIZE);
25
25
 
@@ -15,6 +15,7 @@ export const resolveRelationResource = (resourceNames, relation, sourceResource)
15
15
  };
16
16
 
17
17
  export const getRelationMetadata = (key, resourceNames, sourceResource) => {
18
+ // Relation fields follow the <resource>Id and <resource>Ids conventions.
18
19
  const match = key.match(/^(.+)(Id|Ids)$/);
19
20
 
20
21
  if (match == null) {
package/src/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import Fastify from 'fastify';
2
2
  import { normalizeServerConfig } from './config.js';
3
- import { DEFAULT_HOST, DEFAULT_MAX_FILE_SIZE, DEFAULT_PORT, MAX_PAGE_SIZE } from './constants.js';
3
+ import { DEFAULT_HOST, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_PAGE_SIZE, DEFAULT_PORT } from './constants.js';
4
4
  import { createDatabaseStore, createId, findItem, getCollection } from './database.js';
5
5
  import { createFileStore, registerFileRoutes } from './files.js';
6
6
  import { resolveSchemaConfig } from './openapi/config.js';
@@ -11,14 +11,15 @@ import { embedItem, parseEmbedPaths, validateEmbedPaths } from './relations.js';
11
11
  import { createHttpError, getResourceNames, isObject } from './utils.js';
12
12
 
13
13
  /** @typedef {Record<string, unknown>} OpenapiDocument */
14
+ /** @typedef {{ fastify: () => import('fastify').FastifyInstance, openapi: () => Promise<OpenapiDocument> }} ServerFacade */
14
15
 
15
16
  const CORS_HEADERS = {
16
- 'Access-Control-Allow-Headers': 'Content-Name, Content-Type',
17
+ 'Access-Control-Allow-Headers': 'Content-Directory, Content-Name, Content-Override, Content-Type',
17
18
  'Access-Control-Allow-Methods': 'DELETE, GET, OPTIONS, PATCH, POST, PUT',
18
19
  'Access-Control-Allow-Origin': '*',
19
20
  };
20
21
 
21
- const getRequestBody = (body) => {
22
+ const getJsonObjectBody = (body) => {
22
23
  if (!isObject(body)) {
23
24
  throw createHttpError(400, 'Тело запроса должно быть JSON-объектом');
24
25
  }
@@ -28,7 +29,7 @@ const getRequestBody = (body) => {
28
29
 
29
30
  const getSchemaName = (reference) => reference.split('/').at(-1);
30
31
 
31
- const addRequestSchemas = (server, document, resources) => {
32
+ const addRequestSchemas = (fastify, document, resources) => {
32
33
  const requestSchemaNames = new Set(
33
34
  resources.flatMap((resource) => {
34
35
  const resourcePath = document.paths[`/${resource}`];
@@ -39,17 +40,17 @@ const addRequestSchemas = (server, document, resources) => {
39
40
  );
40
41
 
41
42
  requestSchemaNames.forEach((schemaName) => {
42
- server.addSchema({ $id: schemaName, ...document.components.schemas[schemaName] });
43
+ fastify.addSchema({ $id: schemaName, ...document.components.schemas[schemaName] });
43
44
  });
44
45
  };
45
46
 
46
- const registerResourceRoutes = (server, store, resource, document, maxPageSize) => {
47
+ const registerResourceRoutes = (fastify, store, resource, document, maxPageSize) => {
47
48
  const resourcePath = `/${resource}`;
48
49
  const itemPath = `/${resource}/:id`;
49
50
  const createSchemaName = getSchemaName(document.paths[resourcePath].post.requestBody.content['application/json'].schema.$ref);
50
51
  const updateSchemaName = getSchemaName(document.paths[`/${resource}/{id}`].patch.requestBody.content['application/json'].schema.$ref);
51
52
 
52
- server.get(resourcePath, async (request) => {
53
+ fastify.get(resourcePath, async (request) => {
53
54
  const collection = getCollection(store.database, resource);
54
55
  const where = parseWhere(request.query);
55
56
  const embedPaths = parseEmbedPaths(request.query._embed);
@@ -68,7 +69,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
68
69
  return paginateItems(sortedItems, pagination.page, pagination.pageSize);
69
70
  });
70
71
 
71
- server.get(itemPath, async (request) => {
72
+ fastify.get(itemPath, async (request) => {
72
73
  const collection = getCollection(store.database, resource);
73
74
  const item = findItem(collection, request.params.id);
74
75
  const embedPaths = parseEmbedPaths(request.query._embed);
@@ -82,10 +83,10 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
82
83
  return embedItem(store.database, item, resource, embedPaths);
83
84
  });
84
85
 
85
- server.post(resourcePath, { schema: { body: { $ref: `${createSchemaName}#` } } }, async (request, reply) => {
86
+ fastify.post(resourcePath, { schema: { body: { $ref: `${createSchemaName}#` } } }, async (request, reply) => {
86
87
  const item = await store.update((database) => {
87
88
  const collection = getCollection(database, resource);
88
- const createdItem = { ...getRequestBody(request.body), id: createId(collection) };
89
+ const createdItem = { ...getJsonObjectBody(request.body), id: createId(collection) };
89
90
 
90
91
  collection.push(createdItem);
91
92
 
@@ -95,7 +96,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
95
96
  return reply.code(201).send(item);
96
97
  });
97
98
 
98
- server.put(itemPath, { schema: { body: { $ref: `${createSchemaName}#` } } }, async (request) =>
99
+ fastify.put(itemPath, { schema: { body: { $ref: `${createSchemaName}#` } } }, async (request) =>
99
100
  store.update((database) => {
100
101
  const collection = getCollection(database, resource);
101
102
  const currentItem = findItem(collection, request.params.id);
@@ -104,7 +105,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
104
105
  throw createHttpError(404, 'Запись не найдена');
105
106
  }
106
107
 
107
- const item = { ...getRequestBody(request.body), id: currentItem.id };
108
+ const item = { ...getJsonObjectBody(request.body), id: currentItem.id };
108
109
 
109
110
  collection.splice(collection.indexOf(currentItem), 1, item);
110
111
 
@@ -112,7 +113,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
112
113
  }),
113
114
  );
114
115
 
115
- server.patch(itemPath, { schema: { body: { $ref: `${updateSchemaName}#` } } }, async (request) =>
116
+ fastify.patch(itemPath, { schema: { body: { $ref: `${updateSchemaName}#` } } }, async (request) =>
116
117
  store.update((database) => {
117
118
  const collection = getCollection(database, resource);
118
119
  const currentItem = findItem(collection, request.params.id);
@@ -121,7 +122,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
121
122
  throw createHttpError(404, 'Запись не найдена');
122
123
  }
123
124
 
124
- const item = { ...currentItem, ...getRequestBody(request.body), id: currentItem.id };
125
+ const item = { ...currentItem, ...getJsonObjectBody(request.body), id: currentItem.id };
125
126
 
126
127
  collection.splice(collection.indexOf(currentItem), 1, item);
127
128
 
@@ -129,7 +130,7 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
129
130
  }),
130
131
  );
131
132
 
132
- server.delete(itemPath, async (request) =>
133
+ fastify.delete(itemPath, async (request) =>
133
134
  store.update((database) => {
134
135
  const collection = getCollection(database, resource);
135
136
  const currentItem = findItem(collection, request.params.id);
@@ -146,21 +147,21 @@ const registerResourceRoutes = (server, store, resource, document, maxPageSize)
146
147
  };
147
148
 
148
149
  /**
149
- * Creates a Deep JSON Server facade.
150
- * @param {import('./config.js').DeepJsonServerConfig} options Server options.
150
+ * Creates lazy Fastify and OpenAPI accessors from one configuration.
151
+ * @param {import('./config.js').DeepJsonServerConfig} config Server configuration.
151
152
  * @param {{ files?: boolean }} [features] Optional feature switches.
152
- * @returns {Promise<{ fastify: () => import('fastify').FastifyInstance, openapi: () => Promise<OpenapiDocument> }>} Server facade.
153
+ * @returns {Promise<ServerFacade>} Server facade.
153
154
  */
154
- export async function createServer(options, features = {}) {
155
- const config = normalizeServerConfig(options);
156
- const filesEnabled = features.files ?? config.files != null;
157
- const { logger = true, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxPageSize = MAX_PAGE_SIZE } = config.server;
158
- const store = await createDatabaseStore(config.database);
159
- const schema = await resolveSchemaConfig(config.database.schema);
160
- const fileStore = filesEnabled && config.files != null ? await createFileStore(config.files) : undefined;
155
+ export async function createServer(config, features = {}) {
156
+ const normalizedConfig = normalizeServerConfig(config);
157
+ const filesEnabled = features.files ?? normalizedConfig.files != null;
158
+ const { logger = true, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxPageSize = DEFAULT_MAX_PAGE_SIZE } = normalizedConfig.server;
159
+ const store = await createDatabaseStore(normalizedConfig.database);
160
+ const schema = await resolveSchemaConfig(normalizedConfig.database.schema);
161
+ const fileStore = filesEnabled && normalizedConfig.files != null ? await createFileStore(normalizedConfig.files) : undefined;
161
162
  let fastifyInstance;
162
163
 
163
- if (filesEnabled && config.files == null) {
164
+ if (filesEnabled && normalizedConfig.files == null) {
164
165
  throw new Error('Для файловых маршрутов укажите секцию config.files');
165
166
  }
166
167
 
@@ -172,45 +173,46 @@ export async function createServer(options, features = {}) {
172
173
  schema,
173
174
  });
174
175
 
175
- const fastify = () => {
176
+ const getFastify = () => {
176
177
  if (fastifyInstance != null) {
177
178
  return fastifyInstance;
178
179
  }
179
180
 
180
181
  const document = buildDocument();
181
182
  const resources = getResourceNames(store.database.data);
182
- const server = Fastify({ logger });
183
- const listen = server.listen.bind(server);
183
+ const fastify = Fastify({ logger });
184
+ const originalListen = fastify.listen.bind(fastify);
184
185
 
185
- server.listen = (...args) => listen(...(args.length === 0 ? [{ host: config.server.host ?? DEFAULT_HOST, port: config.server.port ?? DEFAULT_PORT }] : args));
186
+ // Calling fastify().listen() without arguments uses config defaults.
187
+ fastify.listen = (...args) => originalListen(...(args.length === 0 ? [{ host: normalizedConfig.server.host ?? DEFAULT_HOST, port: normalizedConfig.server.port ?? DEFAULT_PORT }] : args));
186
188
 
187
- addRequestSchemas(server, document, resources);
189
+ addRequestSchemas(fastify, document, resources);
188
190
 
189
- server.addHook('onRequest', async (_request, reply) => {
191
+ fastify.addHook('onRequest', async (_request, reply) => {
190
192
  Object.entries(CORS_HEADERS).forEach(([header, value]) => {
191
193
  reply.header(header, value);
192
194
  });
193
195
  });
194
196
 
195
- server.addHook('preHandler', async (request) => {
197
+ fastify.addHook('preHandler', async (request) => {
196
198
  if (request.method === 'GET') {
197
199
  await store.read();
198
200
  }
199
201
  });
200
202
 
201
- server.options('/', async (_request, reply) => reply.code(204).send());
202
- server.options('/*', async (_request, reply) => reply.code(204).send());
203
- server.get('/', async () => ({ resources }));
203
+ fastify.options('/', async (_request, reply) => reply.code(204).send());
204
+ fastify.options('/*', async (_request, reply) => reply.code(204).send());
205
+ fastify.get('/', async () => ({ resources }));
204
206
 
205
207
  resources.forEach((resource) => {
206
- registerResourceRoutes(server, store, resource, document, maxPageSize);
208
+ registerResourceRoutes(fastify, store, resource, document, maxPageSize);
207
209
  });
208
210
 
209
211
  if (fileStore != null) {
210
- registerFileRoutes(server, { maxFileSize, store: fileStore });
212
+ registerFileRoutes(fastify, { maxFileSize, store: fileStore });
211
213
  }
212
214
 
213
- server.setErrorHandler((error, request, reply) => {
215
+ fastify.setErrorHandler((error, request, reply) => {
214
216
  const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
215
217
 
216
218
  if (statusCode === 500) {
@@ -220,7 +222,7 @@ export async function createServer(options, features = {}) {
220
222
  return reply.code(statusCode).send({ error: error.message });
221
223
  });
222
224
 
223
- fastifyInstance = server;
225
+ fastifyInstance = fastify;
224
226
 
225
227
  return fastifyInstance;
226
228
  };
@@ -231,18 +233,18 @@ export async function createServer(options, features = {}) {
231
233
  const document = createOpenapi({
232
234
  database: store.database.data,
233
235
  files: filesEnabled,
234
- host: config.server.host,
236
+ host: normalizedConfig.server.host,
235
237
  maxPageSize,
236
- port: config.server.port,
238
+ port: normalizedConfig.server.port,
237
239
  schema,
238
240
  });
239
241
 
240
- if (config.openapi.path != null) {
241
- await writeOpenapi(document, config.openapi.path);
242
+ if (normalizedConfig.openapi.path != null) {
243
+ await writeOpenapi(document, normalizedConfig.openapi.path);
242
244
  }
243
245
 
244
246
  return document;
245
247
  };
246
248
 
247
- return { fastify, openapi };
249
+ return { fastify: getFastify, openapi };
248
250
  }
package/src/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { resolve } from 'node:path';
2
3
  import pluralize from 'pluralize';
3
4
 
@@ -11,6 +12,35 @@ export const createHttpError = (statusCode, message) => {
11
12
  return error;
12
13
  };
13
14
 
15
+ /** @returns {<T>(operation: () => T | Promise<T>) => Promise<T>} Serialized operation scheduler. */
16
+ export const createSerialQueue = () => {
17
+ let queue = Promise.resolve();
18
+
19
+ return (operation) => {
20
+ const pendingOperation = queue.then(operation);
21
+
22
+ // Keep the queue usable after a failed operation.
23
+ queue = pendingOperation.catch(() => undefined);
24
+
25
+ return pendingOperation;
26
+ };
27
+ };
28
+
29
+ /**
30
+ * Creates a random ID that is not currently in use.
31
+ * @param {(id: string) => boolean} isUsed Checks whether an ID already exists.
32
+ * @returns {string} Unique ID.
33
+ */
34
+ export const createUniqueId = (isUsed) => {
35
+ let id;
36
+
37
+ do {
38
+ id = randomBytes(8).toString('base64url');
39
+ } while (isUsed(id));
40
+
41
+ return id;
42
+ };
43
+
14
44
  export const getResourceNames = (data) => Object.keys(data);
15
45
  export const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
16
46
  export const isSafeKey = (key) => !UNSAFE_KEYS.has(key);
package/types/index.d.ts CHANGED
@@ -2,10 +2,20 @@
2
2
  /** @typedef {import('./src/config.js').DatabaseConfig} DatabaseConfig */
3
3
  /** @typedef {import('./src/config.js').FilesConfig} FilesConfig */
4
4
  /** @typedef {import('./src/config.js').MemoryFile} MemoryFile */
5
+ /** @typedef {import('./src/config.js').OpenapiConfig} OpenapiConfig */
6
+ /** @typedef {import('./src/config.js').ServerConfig} ServerConfig */
7
+ /** @typedef {import('./src/files.js').FileMetadata} FileMetadata */
8
+ /** @typedef {import('./src/files.js').FileUpdate} FileUpdate */
5
9
  /** @typedef {import('./src/server.js').OpenapiDocument} OpenapiDocument */
10
+ /** @typedef {import('./src/server.js').ServerFacade} ServerFacade */
6
11
  export type DeepJsonServerConfig = import('./src/config.js').DeepJsonServerConfig;
7
12
  export type DatabaseConfig = import('./src/config.js').DatabaseConfig;
8
13
  export type FilesConfig = import('./src/config.js').FilesConfig;
9
14
  export type MemoryFile = import('./src/config.js').MemoryFile;
15
+ export type OpenapiConfig = import('./src/config.js').OpenapiConfig;
16
+ export type ServerConfig = import('./src/config.js').ServerConfig;
17
+ export type FileMetadata = import('./src/files.js').FileMetadata;
18
+ export type FileUpdate = import('./src/files.js').FileUpdate;
10
19
  export type OpenapiDocument = import('./src/server.js').OpenapiDocument;
20
+ export type ServerFacade = import('./src/server.js').ServerFacade;
11
21
  export { createServer } from './src/server.js';