@kollors/deep-json-server 0.3.1 → 0.4.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/src/server.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import Fastify from 'fastify';
2
- import { JSONFilePreset } from 'lowdb/node';
3
- import { randomBytes } from 'node:crypto';
4
- import { readFile } from 'node:fs/promises';
5
- import { matchesWhere, paginateItems, parsePagination, parseWhere, sortItems, validateWhere } from './query.js';
6
- import { embedItem, parseEmbedPaths } from './relations.js';
7
- import { createHttpError, getResourceNames, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
2
+ import { DEFAULT_HOST, DEFAULT_PORT, MAX_PAGE_SIZE } from './constants.js';
3
+ import { createDatabaseStore, createId, findItem, getCollection } from './database.js';
4
+ import { readSchemaConfig } from './openapi/config.js';
5
+ import { createOpenApiDocument } from './openapi/index.js';
6
+ import { matchesWhere, paginateItems, parsePagination, parseWhere, sortItems, validateWhere } from './query/index.js';
7
+ import { embedItem, parseEmbedPaths, validateEmbedPaths } from './relations.js';
8
+ import { createHttpError, getResourceNames, isObject, resolveDatabasePath } from './utils.js';
8
9
 
9
10
  const CORS_HEADERS = {
10
11
  'Access-Control-Allow-Headers': 'Content-Type',
@@ -12,16 +13,6 @@ const CORS_HEADERS = {
12
13
  'Access-Control-Allow-Origin': '*',
13
14
  };
14
15
 
15
- const getCollection = (database, resource) => {
16
- const collection = isSafeKey(resource) ? database.data[resource] : undefined;
17
-
18
- if (!Array.isArray(collection)) {
19
- throw createHttpError(404, 'Ресурс не найден');
20
- }
21
-
22
- return collection;
23
- };
24
-
25
16
  const getRequestBody = (body) => {
26
17
  if (!isObject(body)) {
27
18
  throw createHttpError(400, 'Тело запроса должно быть JSON-объектом');
@@ -30,111 +21,66 @@ const getRequestBody = (body) => {
30
21
  return body;
31
22
  };
32
23
 
33
- const findItem = (collection, id) => collection.find((item) => isObject(item) && String(item.id) === id);
34
-
35
- const validateDatabase = (data) => {
36
- if (!isObject(data)) {
37
- throw new Error('База данных должна содержать JSON-объект');
38
- }
39
-
40
- const invalidResource = Object.entries(data).find(([key, value]) => !key.startsWith('$') && !Array.isArray(value));
41
-
42
- if (invalidResource != null) {
43
- throw new Error(`Ресурс «${invalidResource[0]}» должен содержать JSON-массив`);
44
- }
45
- };
46
-
47
- const readDatabaseFile = async(databasePath) => {
48
- let source;
49
-
50
- try {
51
- source = await readFile(databasePath, 'utf8');
52
- } catch (error) {
53
- if (error?.code === 'ENOENT') {
54
- throw new Error(`Файл базы данных не найден: ${databasePath}`);
55
- }
56
-
57
- throw error;
58
- }
59
-
60
- const data = JSON.parse(source);
61
-
62
- validateDatabase(data);
63
-
64
- return data;
65
- };
66
-
67
- export async function createServer({ databasePath, logger = true } = {}) {
68
- const resolvedDatabasePath = resolveDatabasePath(databasePath);
69
- const initialData = await readDatabaseFile(resolvedDatabasePath);
70
- const database = await JSONFilePreset(resolvedDatabasePath, initialData);
71
- const server = Fastify({ logger });
72
- let databaseWriteQueue = Promise.resolve();
73
-
74
- const readDatabase = async() => {
75
- database.data = await readDatabaseFile(resolvedDatabasePath);
76
- };
77
-
78
- const updateDatabase = (update) => {
79
- const operation = databaseWriteQueue.then(async() => {
80
- await readDatabase();
81
-
82
- const result = update();
83
-
84
- await database.write();
85
-
86
- return result;
87
- });
88
-
89
- databaseWriteQueue = operation.catch(() => undefined);
24
+ const getSchemaName = (reference) => reference.split('/').at(-1);
90
25
 
91
- return operation;
92
- };
26
+ const addRequestSchemas = (server, document, resources) => {
27
+ const requestSchemaNames = new Set(
28
+ resources.flatMap((resource) => {
29
+ const resourcePath = document.paths[`/${resource}`];
30
+ const itemPath = document.paths[`/${resource}/{id}`];
93
31
 
94
- server.addHook('onRequest', async(_request, reply) => {
95
- Object.entries(CORS_HEADERS).forEach(([header, value]) => reply.header(header, value));
96
- });
32
+ return [getSchemaName(resourcePath.post.requestBody.content['application/json'].schema.$ref), getSchemaName(itemPath.patch.requestBody.content['application/json'].schema.$ref)];
33
+ }),
34
+ );
97
35
 
98
- server.addHook('preHandler', async(request) => {
99
- if (request.method === 'GET') {
100
- await readDatabase();
101
- }
36
+ requestSchemaNames.forEach((schemaName) => {
37
+ server.addSchema({ $id: schemaName, ...document.components.schemas[schemaName] });
102
38
  });
39
+ };
103
40
 
104
- server.options('/', async(_request, reply) => reply.code(204).send());
105
- server.options('/*', async(_request, reply) => reply.code(204).send());
106
- server.get('/', async() => ({ resources: getResourceNames(database.data) }));
41
+ const registerResourceRoutes = (server, store, resource, document, maxPageSize) => {
42
+ const resourcePath = `/${resource}`;
43
+ const itemPath = `/${resource}/:id`;
44
+ const createSchemaName = getSchemaName(document.paths[resourcePath].post.requestBody.content['application/json'].schema.$ref);
45
+ const updateSchemaName = getSchemaName(document.paths[`/${resource}/{id}`].patch.requestBody.content['application/json'].schema.$ref);
107
46
 
108
- server.get('/:resource', async(request) => {
109
- const collection = getCollection(database, request.params.resource);
47
+ server.get(resourcePath, async (request) => {
48
+ const collection = getCollection(store.database, resource);
110
49
  const where = parseWhere(request.query);
111
50
  const embedPaths = parseEmbedPaths(request.query._embed);
51
+ const pagination = parsePagination(request.query, maxPageSize);
52
+
53
+ validateEmbedPaths(store.database, resource, collection, embedPaths);
54
+
112
55
  const relationIndexes = new Map();
113
- const embeddedItems = collection.map((item) => embedItem(database, item, request.params.resource, embedPaths, relationIndexes));
114
- const pagination = parsePagination(request.query);
56
+ const embeddedItems = collection.map((item) => embedItem(store.database, item, resource, embedPaths, relationIndexes));
115
57
 
116
58
  validateWhere(where, embeddedItems);
117
59
 
118
60
  const filteredItems = embeddedItems.filter((item) => matchesWhere(item, where));
119
- const sortedItems = sortItems(filteredItems, request.query._sort);
61
+ const sortedItems = sortItems(filteredItems, request.query._sort, embeddedItems);
120
62
 
121
63
  return paginateItems(sortedItems, pagination.page, pagination.pageSize);
122
64
  });
123
65
 
124
- server.get('/:resource/:id', async(request) => {
125
- const item = findItem(getCollection(database, request.params.resource), request.params.id);
66
+ server.get(itemPath, async (request) => {
67
+ const collection = getCollection(store.database, resource);
68
+ const item = findItem(collection, request.params.id);
69
+ const embedPaths = parseEmbedPaths(request.query._embed);
126
70
 
127
71
  if (item == null) {
128
72
  throw createHttpError(404, 'Запись не найдена');
129
73
  }
130
74
 
131
- return embedItem(database, item, request.params.resource, parseEmbedPaths(request.query._embed));
75
+ validateEmbedPaths(store.database, resource, collection, embedPaths);
76
+
77
+ return embedItem(store.database, item, resource, embedPaths);
132
78
  });
133
79
 
134
- server.post('/:resource', async(request, reply) => {
135
- const item = await updateDatabase(() => {
136
- const collection = getCollection(database, request.params.resource);
137
- const createdItem = { ...getRequestBody(request.body), id: randomBytes(8).toString('base64url') };
80
+ server.post(resourcePath, { schema: { body: { $ref: `${createSchemaName}#` } } }, async (request, reply) => {
81
+ const item = await store.update((database) => {
82
+ const collection = getCollection(database, resource);
83
+ const createdItem = { ...getRequestBody(request.body), id: createId(collection) };
138
84
 
139
85
  collection.push(createdItem);
140
86
 
@@ -144,9 +90,9 @@ export async function createServer({ databasePath, logger = true } = {}) {
144
90
  return reply.code(201).send(item);
145
91
  });
146
92
 
147
- server.put('/:resource/:id', async(request) => {
148
- return updateDatabase(() => {
149
- const collection = getCollection(database, request.params.resource);
93
+ server.put(itemPath, { schema: { body: { $ref: `${createSchemaName}#` } } }, async (request) =>
94
+ store.update((database) => {
95
+ const collection = getCollection(database, resource);
150
96
  const currentItem = findItem(collection, request.params.id);
151
97
 
152
98
  if (currentItem == null) {
@@ -158,12 +104,12 @@ export async function createServer({ databasePath, logger = true } = {}) {
158
104
  collection.splice(collection.indexOf(currentItem), 1, item);
159
105
 
160
106
  return item;
161
- });
162
- });
107
+ }),
108
+ );
163
109
 
164
- server.patch('/:resource/:id', async(request) => {
165
- return updateDatabase(() => {
166
- const collection = getCollection(database, request.params.resource);
110
+ server.patch(itemPath, { schema: { body: { $ref: `${updateSchemaName}#` } } }, async (request) =>
111
+ store.update((database) => {
112
+ const collection = getCollection(database, resource);
167
113
  const currentItem = findItem(collection, request.params.id);
168
114
 
169
115
  if (currentItem == null) {
@@ -175,12 +121,12 @@ export async function createServer({ databasePath, logger = true } = {}) {
175
121
  collection.splice(collection.indexOf(currentItem), 1, item);
176
122
 
177
123
  return item;
178
- });
179
- });
124
+ }),
125
+ );
180
126
 
181
- server.delete('/:resource/:id', async(request) => {
182
- return updateDatabase(() => {
183
- const collection = getCollection(database, request.params.resource);
127
+ server.delete(itemPath, async (request) =>
128
+ store.update((database) => {
129
+ const collection = getCollection(database, resource);
184
130
  const currentItem = findItem(collection, request.params.id);
185
131
 
186
132
  if (currentItem == null) {
@@ -190,9 +136,50 @@ export async function createServer({ databasePath, logger = true } = {}) {
190
136
  collection.splice(collection.indexOf(currentItem), 1);
191
137
 
192
138
  return currentItem;
139
+ }),
140
+ );
141
+ };
142
+
143
+ /**
144
+ * Creates a Fastify server without opening a network port.
145
+ * @param {{ databasePath: string, logger?: boolean | Record<string, unknown>, maxPageSize?: number, schemaPath?: string }} options Server options.
146
+ * @returns {Promise<import('fastify').FastifyInstance>} Fastify server.
147
+ */
148
+ export async function createServer(options) {
149
+ const { databasePath, logger = true, maxPageSize = MAX_PAGE_SIZE, schemaPath } = options ?? {};
150
+
151
+ if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
152
+ throw new Error('Максимальный размер страницы должен быть положительным целым числом');
153
+ }
154
+
155
+ const store = await createDatabaseStore(databasePath);
156
+ const schemaConfig = await readSchemaConfig(schemaPath);
157
+ const document = createOpenApiDocument(store.database.data, schemaConfig);
158
+ const resources = getResourceNames(store.database.data);
159
+ const server = Fastify({ logger });
160
+
161
+ addRequestSchemas(server, document, resources);
162
+
163
+ server.addHook('onRequest', async (_request, reply) => {
164
+ Object.entries(CORS_HEADERS).forEach(([header, value]) => {
165
+ reply.header(header, value);
193
166
  });
194
167
  });
195
168
 
169
+ server.addHook('preHandler', async (request) => {
170
+ if (request.method === 'GET') {
171
+ await store.read();
172
+ }
173
+ });
174
+
175
+ server.options('/', async (_request, reply) => reply.code(204).send());
176
+ server.options('/*', async (_request, reply) => reply.code(204).send());
177
+ server.get('/', async () => ({ resources }));
178
+
179
+ resources.forEach((resource) => {
180
+ registerResourceRoutes(server, store, resource, document, maxPageSize);
181
+ });
182
+
196
183
  server.setErrorHandler((error, request, reply) => {
197
184
  const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
198
185
 
@@ -206,13 +193,20 @@ export async function createServer({ databasePath, logger = true } = {}) {
206
193
  return server;
207
194
  }
208
195
 
209
- export async function startServer({ databasePath, host = '127.0.0.1', logger = true, port = 4001 } = {}) {
210
- if (!Number.isInteger(port) || port < 1 || port > 65_535) {
211
- throw new Error('Порт должен быть целым числом от 1 до 65535');
196
+ /**
197
+ * Creates and starts a Fastify server.
198
+ * @param {{ databasePath: string, host?: string, logger?: boolean | Record<string, unknown>, maxPageSize?: number, port?: number, schemaPath?: string }} options Server options.
199
+ * @returns {Promise<import('fastify').FastifyInstance>} Listening Fastify server.
200
+ */
201
+ export async function startServer(options) {
202
+ const { databasePath, host = DEFAULT_HOST, logger = true, maxPageSize = MAX_PAGE_SIZE, port = DEFAULT_PORT, schemaPath } = options ?? {};
203
+
204
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
205
+ throw new Error('Порт должен быть целым числом от 0 до 65535');
212
206
  }
213
207
 
214
208
  const resolvedDatabasePath = resolveDatabasePath(databasePath);
215
- const server = await createServer({ databasePath: resolvedDatabasePath, logger });
209
+ const server = await createServer({ databasePath: resolvedDatabasePath, logger, maxPageSize, schemaPath });
216
210
 
217
211
  await server.listen({ host, port });
218
212
  server.log.info({ database: resolvedDatabasePath }, 'Deep JSON Server запущен');
package/src/utils.js CHANGED
@@ -1,6 +1,4 @@
1
- import { realpathSync } from 'node:fs';
2
1
  import { resolve } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
2
  import pluralize from 'pluralize';
5
3
 
6
4
  const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -13,8 +11,7 @@ export const createHttpError = (statusCode, message) => {
13
11
  return error;
14
12
  };
15
13
 
16
- export const getResourceNames = (data) => Object.entries(data).filter(([, value]) => Array.isArray(value)).map(([resource]) => resource);
17
- export const isMainModule = (filePath, moduleUrl) => filePath != null && realpathSync(resolve(filePath)) === fileURLToPath(moduleUrl);
14
+ export const getResourceNames = (data) => Object.keys(data);
18
15
  export const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
19
16
  export const isSafeKey = (key) => !UNSAFE_KEYS.has(key);
20
17
  export const toArray = (value) => (Array.isArray(value) ? value : [value]);
@@ -46,6 +43,12 @@ export const resolveDatabasePath = (databasePath) => {
46
43
  return resolve(databasePath);
47
44
  };
48
45
 
46
+ export const isIdEqual = (left, right) => left != null && right != null && String(left) === String(right);
49
47
  export const singularize = (value) => pluralize.singular(value);
50
48
 
51
- export const toPascalCase = (value) => value.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join('');
49
+ export const toPascalCase = (value) =>
50
+ value
51
+ .split(/[^a-zA-Z0-9]+/)
52
+ .filter(Boolean)
53
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
54
+ .join('');
@@ -0,0 +1,2 @@
1
+ export { createOpenApiDocument, generateOpenApi } from './src/openapi/index.js';
2
+ export { createServer, startServer } from './src/server.js';
@@ -0,0 +1,4 @@
1
+ export declare const DEFAULT_HOST = "127.0.0.1";
2
+ export declare const DEFAULT_PAGE_SIZE = 10;
3
+ export declare const DEFAULT_PORT = 4001;
4
+ export declare const MAX_PAGE_SIZE = 1000;
@@ -0,0 +1,8 @@
1
+ export declare const validateDatabase: (data: any) => any;
2
+ export declare const readJsonObject: (path: any, label: any) => Promise<any>;
3
+ export declare const readDatabaseFile: (databasePath: any) => Promise<any>;
4
+ /** @returns {Promise<any>} Internal LowDB-backed store. */
5
+ export declare const createDatabaseStore: (databasePath: any) => Promise<any>;
6
+ export declare const getCollection: (database: any, resource: any) => any[];
7
+ export declare const findItem: (collection: any, id: any) => any;
8
+ export declare const createId: (collection: any) => any;
@@ -0,0 +1,3 @@
1
+ export declare const validateSchemaConfig: (schemaConfig: any, resources: any) => any;
2
+ export declare const applyConfiguredFields: (schema: any, resource: any, resourceConfig: any) => any;
3
+ export declare const readSchemaConfig: (schemaPath: any) => Promise<any>;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Creates an OpenAPI 3.0 document from a database and optional schema configuration.
3
+ * @param {Record<string, Array<Record<string, unknown>>>} database Database contents.
4
+ * @param {Record<string, unknown>} [schemaConfig] Schema configuration.
5
+ * @param {{ host?: string, port?: number }} [serverOptions] Server address used in the generated document.
6
+ * @returns {Record<string, unknown>} OpenAPI document.
7
+ */
8
+ export declare function createOpenApiDocument(database: Record<string, Array<Record<string, unknown>>>, schemaConfig?: Record<string, unknown>, { host, port }?: {
9
+ host?: string;
10
+ port?: number;
11
+ }): Record<string, unknown>;
@@ -0,0 +1,13 @@
1
+ export { createOpenApiDocument } from './document.js';
2
+ /**
3
+ * Generates an OpenAPI YAML file.
4
+ * @param {{ databasePath: string, host?: string, outputPath: string, port?: number, schemaPath: string }} options Generation options.
5
+ * @returns {Promise<Record<string, unknown>>} Generated OpenAPI document.
6
+ */
7
+ export declare function generateOpenApi({ databasePath, host, outputPath, port, schemaPath }: {
8
+ databasePath: string;
9
+ host?: string;
10
+ outputPath: string;
11
+ port?: number;
12
+ schemaPath: string;
13
+ }): Promise<Record<string, unknown>>;
@@ -0,0 +1,9 @@
1
+ export declare const mergeSchemas: (schemas: any) => any;
2
+ export declare const mergeSchemaOverrides: (schema: any, overrides: any) => any;
3
+ export declare const applyRequiredFields: (schema: any, path: any, requiredFields: any) => any;
4
+ export declare const inferSchema: (values: any) => any;
5
+ export declare function inferObjectSchema(values: any): any;
6
+ export declare const ensureGeneratedIdSchema: (schema: any) => any;
7
+ export declare const getSchemasAtPath: (schema: any, keys: any) => any;
8
+ export declare const updateSchemasAtPath: (schema: any, keys: any, update: any) => any;
9
+ export declare const omitId: (schema: any, keepRequired: any) => any;
@@ -0,0 +1,3 @@
1
+ export declare function matchesWhere(value: any, where: any): any;
2
+ export declare const parseWhere: (query: any) => any;
3
+ export declare const validateWhere: (where: any, items: any, path?: string) => void;
@@ -0,0 +1,3 @@
1
+ export { matchesWhere, parseWhere, validateWhere } from './filter.js';
2
+ export { paginateItems, parsePagination } from './pagination.js';
3
+ export { sortItems } from './sort.js';
@@ -0,0 +1,13 @@
1
+ export declare const parsePagination: (query: any, maxPageSize?: number) => {
2
+ page: any;
3
+ pageSize: any;
4
+ };
5
+ export declare const paginateItems: (items: any, page: any, pageSize: any) => {
6
+ data: any;
7
+ first: number;
8
+ items: any;
9
+ last: number;
10
+ next: any;
11
+ pages: number;
12
+ prev: number | null;
13
+ };
@@ -0,0 +1 @@
1
+ export declare const sortItems: (items: any, sort: any, validationItems?: any) => any[];
@@ -0,0 +1,9 @@
1
+ export declare const getRelationKeys: (...names: any[]) => string[];
2
+ export declare const resolveRelationResource: (resourceNames: any, relation: any, sourceResource: any) => any;
3
+ export declare const getRelationMetadata: (key: any, resourceNames: any, sourceResource: any) => {
4
+ isMany: boolean;
5
+ relationName: any;
6
+ reverseRelationName: any;
7
+ sourceResource: any;
8
+ targetResource: any;
9
+ } | undefined;
@@ -0,0 +1,3 @@
1
+ export declare const parseEmbedPaths: (embed: any) => any[];
2
+ export declare const validateEmbedPaths: (database: any, resource: any, items: any, embedPaths: any) => void;
3
+ export declare const embedItem: (database: any, item: any, resource: any, embedPaths: any, indexes?: Map<any, any>) => any;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Creates a Fastify server without opening a network port.
3
+ * @param {{ databasePath: string, logger?: boolean | Record<string, unknown>, maxPageSize?: number, schemaPath?: string }} options Server options.
4
+ * @returns {Promise<import('fastify').FastifyInstance>} Fastify server.
5
+ */
6
+ export declare function createServer(options: {
7
+ databasePath: string;
8
+ logger?: boolean | Record<string, unknown>;
9
+ maxPageSize?: number;
10
+ schemaPath?: string;
11
+ }): Promise<import('fastify').FastifyInstance>;
12
+ /**
13
+ * Creates and starts a Fastify server.
14
+ * @param {{ databasePath: string, host?: string, logger?: boolean | Record<string, unknown>, maxPageSize?: number, port?: number, schemaPath?: string }} options Server options.
15
+ * @returns {Promise<import('fastify').FastifyInstance>} Listening Fastify server.
16
+ */
17
+ export declare function startServer(options: {
18
+ databasePath: string;
19
+ host?: string;
20
+ logger?: boolean | Record<string, unknown>;
21
+ maxPageSize?: number;
22
+ port?: number;
23
+ schemaPath?: string;
24
+ }): Promise<import('fastify').FastifyInstance>;
@@ -0,0 +1,10 @@
1
+ export declare const createHttpError: (statusCode: any, message: any) => Error;
2
+ export declare const getResourceNames: (data: any) => string[];
3
+ export declare const isObject: (value: any) => boolean;
4
+ export declare const isSafeKey: (key: any) => boolean;
5
+ export declare const toArray: (value: any) => any[];
6
+ export declare const isEqual: (left: any, right: any) => any;
7
+ export declare const resolveDatabasePath: (databasePath: any) => any;
8
+ export declare const isIdEqual: (left: any, right: any) => boolean;
9
+ export declare const singularize: (value: any) => any;
10
+ export declare const toPascalCase: (value: any) => any;