@kollors/deep-json-server 0.3.2 → 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, validateDatabase } 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,97 +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 readDatabaseFile = async(databasePath) => {
36
- let source;
37
-
38
- try {
39
- source = await readFile(databasePath, 'utf8');
40
- } catch (error) {
41
- if (error?.code === 'ENOENT') {
42
- throw new Error(`Файл базы данных не найден: ${databasePath}`);
43
- }
44
-
45
- throw error;
46
- }
47
-
48
- const data = JSON.parse(source);
49
-
50
- return validateDatabase(data);
51
- };
52
-
53
- export async function createServer({ databasePath, logger = true } = {}) {
54
- const resolvedDatabasePath = resolveDatabasePath(databasePath);
55
- const initialData = await readDatabaseFile(resolvedDatabasePath);
56
- const database = await JSONFilePreset(resolvedDatabasePath, initialData);
57
- const server = Fastify({ logger });
58
- let databaseWriteQueue = Promise.resolve();
59
-
60
- const readDatabase = async() => {
61
- database.data = await readDatabaseFile(resolvedDatabasePath);
62
- };
24
+ const getSchemaName = (reference) => reference.split('/').at(-1);
63
25
 
64
- const updateDatabase = (update) => {
65
- const operation = databaseWriteQueue.then(async() => {
66
- await readDatabase();
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}`];
67
31
 
68
- const result = update();
69
-
70
- await database.write();
71
-
72
- return result;
73
- });
32
+ return [getSchemaName(resourcePath.post.requestBody.content['application/json'].schema.$ref), getSchemaName(itemPath.patch.requestBody.content['application/json'].schema.$ref)];
33
+ }),
34
+ );
74
35
 
75
- databaseWriteQueue = operation.catch(() => undefined);
76
-
77
- return operation;
78
- };
79
-
80
- server.addHook('onRequest', async(_request, reply) => {
81
- Object.entries(CORS_HEADERS).forEach(([header, value]) => reply.header(header, value));
82
- });
83
-
84
- server.addHook('preHandler', async(request) => {
85
- if (request.method === 'GET') {
86
- await readDatabase();
87
- }
36
+ requestSchemaNames.forEach((schemaName) => {
37
+ server.addSchema({ $id: schemaName, ...document.components.schemas[schemaName] });
88
38
  });
39
+ };
89
40
 
90
- server.options('/', async(_request, reply) => reply.code(204).send());
91
- server.options('/*', async(_request, reply) => reply.code(204).send());
92
- 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);
93
46
 
94
- server.get('/:resource', async(request) => {
95
- const collection = getCollection(database, request.params.resource);
47
+ server.get(resourcePath, async (request) => {
48
+ const collection = getCollection(store.database, resource);
96
49
  const where = parseWhere(request.query);
97
50
  const embedPaths = parseEmbedPaths(request.query._embed);
51
+ const pagination = parsePagination(request.query, maxPageSize);
52
+
53
+ validateEmbedPaths(store.database, resource, collection, embedPaths);
54
+
98
55
  const relationIndexes = new Map();
99
- const embeddedItems = collection.map((item) => embedItem(database, item, request.params.resource, embedPaths, relationIndexes));
100
- const pagination = parsePagination(request.query);
56
+ const embeddedItems = collection.map((item) => embedItem(store.database, item, resource, embedPaths, relationIndexes));
101
57
 
102
58
  validateWhere(where, embeddedItems);
103
59
 
104
60
  const filteredItems = embeddedItems.filter((item) => matchesWhere(item, where));
105
- const sortedItems = sortItems(filteredItems, request.query._sort);
61
+ const sortedItems = sortItems(filteredItems, request.query._sort, embeddedItems);
106
62
 
107
63
  return paginateItems(sortedItems, pagination.page, pagination.pageSize);
108
64
  });
109
65
 
110
- server.get('/:resource/:id', async(request) => {
111
- 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);
112
70
 
113
71
  if (item == null) {
114
72
  throw createHttpError(404, 'Запись не найдена');
115
73
  }
116
74
 
117
- 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);
118
78
  });
119
79
 
120
- server.post('/:resource', async(request, reply) => {
121
- const item = await updateDatabase(() => {
122
- const collection = getCollection(database, request.params.resource);
123
- 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) };
124
84
 
125
85
  collection.push(createdItem);
126
86
 
@@ -130,9 +90,9 @@ export async function createServer({ databasePath, logger = true } = {}) {
130
90
  return reply.code(201).send(item);
131
91
  });
132
92
 
133
- server.put('/:resource/:id', async(request) => {
134
- return updateDatabase(() => {
135
- 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);
136
96
  const currentItem = findItem(collection, request.params.id);
137
97
 
138
98
  if (currentItem == null) {
@@ -144,12 +104,12 @@ export async function createServer({ databasePath, logger = true } = {}) {
144
104
  collection.splice(collection.indexOf(currentItem), 1, item);
145
105
 
146
106
  return item;
147
- });
148
- });
107
+ }),
108
+ );
149
109
 
150
- server.patch('/:resource/:id', async(request) => {
151
- return updateDatabase(() => {
152
- 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);
153
113
  const currentItem = findItem(collection, request.params.id);
154
114
 
155
115
  if (currentItem == null) {
@@ -161,12 +121,12 @@ export async function createServer({ databasePath, logger = true } = {}) {
161
121
  collection.splice(collection.indexOf(currentItem), 1, item);
162
122
 
163
123
  return item;
164
- });
165
- });
124
+ }),
125
+ );
166
126
 
167
- server.delete('/:resource/:id', async(request) => {
168
- return updateDatabase(() => {
169
- const collection = getCollection(database, request.params.resource);
127
+ server.delete(itemPath, async (request) =>
128
+ store.update((database) => {
129
+ const collection = getCollection(database, resource);
170
130
  const currentItem = findItem(collection, request.params.id);
171
131
 
172
132
  if (currentItem == null) {
@@ -176,9 +136,50 @@ export async function createServer({ databasePath, logger = true } = {}) {
176
136
  collection.splice(collection.indexOf(currentItem), 1);
177
137
 
178
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);
179
166
  });
180
167
  });
181
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
+
182
183
  server.setErrorHandler((error, request, reply) => {
183
184
  const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
184
185
 
@@ -192,13 +193,20 @@ export async function createServer({ databasePath, logger = true } = {}) {
192
193
  return server;
193
194
  }
194
195
 
195
- export async function startServer({ databasePath, host = '127.0.0.1', logger = true, port = 4001 } = {}) {
196
- if (!Number.isInteger(port) || port < 1 || port > 65_535) {
197
- 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');
198
206
  }
199
207
 
200
208
  const resolvedDatabasePath = resolveDatabasePath(databasePath);
201
- const server = await createServer({ databasePath: resolvedDatabasePath, logger });
209
+ const server = await createServer({ databasePath: resolvedDatabasePath, logger, maxPageSize, schemaPath });
202
210
 
203
211
  await server.listen({ host, port });
204
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']);
@@ -14,25 +12,10 @@ export const createHttpError = (statusCode, message) => {
14
12
  };
15
13
 
16
14
  export const getResourceNames = (data) => Object.keys(data);
17
- export const isMainModule = (filePath, moduleUrl) => filePath != null && realpathSync(resolve(filePath)) === fileURLToPath(moduleUrl);
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]);
21
18
 
22
- export const validateDatabase = (data) => {
23
- if (!isObject(data)) {
24
- throw new Error('База данных должна содержать JSON-объект');
25
- }
26
-
27
- const invalidResource = Object.entries(data).find(([, value]) => !Array.isArray(value));
28
-
29
- if (invalidResource != null) {
30
- throw new Error(`Ресурс «${invalidResource[0]}» должен содержать JSON-массив`);
31
- }
32
-
33
- return data;
34
- };
35
-
36
19
  export const isEqual = (left, right) => {
37
20
  if (Object.is(left, right)) {
38
21
  return true;
@@ -60,6 +43,12 @@ export const resolveDatabasePath = (databasePath) => {
60
43
  return resolve(databasePath);
61
44
  };
62
45
 
46
+ export const isIdEqual = (left, right) => left != null && right != null && String(left) === String(right);
63
47
  export const singularize = (value) => pluralize.singular(value);
64
48
 
65
- 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;