@kollors/deep-json-server 0.5.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.
@@ -107,4 +107,18 @@ export const applyConfiguredFields = (schema, resource, resourceConfig) => {
107
107
  return applyRequiredFields(schemaWithFormats, '', new Set(requiredFields));
108
108
  };
109
109
 
110
- export const readSchemaConfig = async (schemaPath) => (schemaPath == null ? {} : readJsonObject(schemaPath, 'Файл схемы базы данных'));
110
+ export const resolveSchemaConfig = async (schema) => {
111
+ if (schema == null) {
112
+ return {};
113
+ }
114
+
115
+ if (typeof schema === 'string') {
116
+ return readJsonObject(schema, 'Файл схемы базы данных');
117
+ }
118
+
119
+ if (!isObject(schema)) {
120
+ throw new Error('Схема базы данных должна содержать JSON-объект');
121
+ }
122
+
123
+ return structuredClone(schema);
124
+ };
@@ -1,26 +1,10 @@
1
- import { DEFAULT_HOST, DEFAULT_PAGE_SIZE, DEFAULT_PORT, 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';
5
5
  import { applyConfiguredFields, validateSchemaConfig } from './config.js';
6
6
  import { ensureGeneratedIdSchema, inferObjectSchema, mergeSchemaOverrides, omitId } from './inference.js';
7
7
 
8
- const getServerUrl = (host, port) => {
9
- const serverPort = Number(port);
10
-
11
- if (typeof host !== 'string' || host === '') {
12
- throw new Error('Адрес сервера не должен быть пустым');
13
- }
14
-
15
- if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65_535) {
16
- throw new Error('Порт должен быть целым числом от 1 до 65535');
17
- }
18
-
19
- const serverHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
20
-
21
- return `http://${serverHost}:${serverPort}`;
22
- };
23
-
24
8
  const createSchemaReference = (name) => ({ $ref: `#/components/schemas/${name}` });
25
9
 
26
10
  const addForwardRelations = (schema, resources, componentNames, sourceResource) => {
@@ -100,12 +84,15 @@ const addReverseRelations = (schemas, rawSchemas, resources, componentNames) =>
100
84
  });
101
85
  };
102
86
 
103
- const createParameters = () => ({
104
- ContentName: { description: 'URI-encoded relative file name', in: 'header', name: 'Content-Name', required: true, schema: { type: 'string' } },
87
+ const createParameters = (maxPageSize) => ({
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' } },
105
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' } },
106
93
  Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } },
107
94
  Page: { in: 'query', name: '_page', required: false, schema: { default: 1, minimum: 1, type: 'integer' } },
108
- PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum: MAX_PAGE_SIZE, minimum: 1, type: 'integer' } },
95
+ PerPage: { in: 'query', name: '_perPage', required: false, schema: { default: DEFAULT_PAGE_SIZE, maximum: maxPageSize, minimum: 1, type: 'integer' } },
109
96
  Sort: { description: 'Comma-separated fields; prefix with - for descending order', in: 'query', name: '_sort', schema: { type: 'string' } },
110
97
  Where: { description: 'JSON-encoded deep filter', in: 'query', name: '_where', schema: { type: 'string' } },
111
98
  });
@@ -116,52 +103,110 @@ const createParameterReference = (name) => ({ $ref: `#/components/parameters/${n
116
103
  const createRequestBody = (name) => ({ required: true, ...createJsonContent(createSchemaReference(name)) });
117
104
 
118
105
  const createFilePaths = () => ({
119
- '/_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': {
120
131
  post: {
121
132
  operationId: 'uploadFile',
122
- parameters: [createParameterReference('ContentName')],
133
+ parameters: ['ContentName', 'ContentDirectory', 'ContentOverride'].map(createParameterReference),
123
134
  requestBody: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, required: true },
124
135
  responses: {
125
- 201: createResponse('Uploaded', createSchemaReference('UploadedFile')),
136
+ 200: createResponse('Overwritten', createSchemaReference('FileMetadata')),
137
+ 201: createResponse('Created', createSchemaReference('FileMetadata')),
126
138
  400: createResponse('Invalid request', createSchemaReference('Error')),
139
+ 409: createResponse('Already exists', createSchemaReference('Error')),
127
140
  413: createResponse('File is too large', createSchemaReference('Error')),
128
141
  415: createResponse('Unsupported media type', createSchemaReference('Error')),
129
142
  },
130
143
  tags: ['files'],
131
144
  },
132
145
  },
133
- '/_files/{id}': {
146
+ '/_files/storage/{path}': {
134
147
  delete: {
135
- operationId: 'deleteFileById',
136
- parameters: [createParameterReference('Id')],
137
- 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
+ },
138
155
  tags: ['files'],
139
156
  },
140
157
  get: {
141
- operationId: 'getFileById',
142
- parameters: [createParameterReference('Id')],
158
+ operationId: 'getFileContent',
159
+ parameters: [createParameterReference('FilePath')],
143
160
  responses: {
144
161
  200: { content: { 'application/octet-stream': { schema: { format: 'binary', type: 'string' } } }, description: 'File contents' },
162
+ 400: createResponse('Invalid path', createSchemaReference('Error')),
145
163
  404: createResponse('Not found', createSchemaReference('Error')),
146
164
  },
147
165
  tags: ['files'],
148
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')),
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')),
178
+ },
179
+ tags: ['files'],
180
+ },
149
181
  },
150
182
  });
151
183
 
152
- const createResourcePaths = (resource, componentName) => {
184
+ const createResourceOperationIds = (resource) => {
153
185
  const resourceName = toPascalCase(resource);
154
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
+
155
200
  return {
156
201
  [`/${resource}`]: {
157
202
  get: {
158
- operationId: `get${resourceName}`,
203
+ operationId: operationIds.list,
159
204
  parameters: ['Page', 'PerPage', 'Sort', 'Where', 'Embed'].map(createParameterReference),
160
205
  responses: { 200: createResponse('Successful response', createSchemaReference(`${componentName}Page`)), 400: createResponse('Invalid query', createSchemaReference('Error')) },
161
206
  tags: [resource],
162
207
  },
163
208
  post: {
164
- operationId: `post${resourceName}`,
209
+ operationId: operationIds.create,
165
210
  requestBody: createRequestBody(`${componentName}Create`),
166
211
  responses: { 201: createResponse('Created', createSchemaReference(componentName)), 400: createResponse('Invalid request', createSchemaReference('Error')) },
167
212
  tags: [resource],
@@ -169,13 +214,13 @@ const createResourcePaths = (resource, componentName) => {
169
214
  },
170
215
  [`/${resource}/{id}`]: {
171
216
  delete: {
172
- operationId: `delete${resourceName}ById`,
217
+ operationId: operationIds.remove,
173
218
  parameters: [createParameterReference('Id')],
174
219
  responses: { 200: createResponse('Deleted', createSchemaReference(componentName)), 404: createResponse('Not found', createSchemaReference('Error')) },
175
220
  tags: [resource],
176
221
  },
177
222
  get: {
178
- operationId: `get${resourceName}ById`,
223
+ operationId: operationIds.get,
179
224
  parameters: [createParameterReference('Id'), createParameterReference('Embed')],
180
225
  responses: {
181
226
  200: createResponse('Successful response', createSchemaReference(componentName)),
@@ -185,7 +230,7 @@ const createResourcePaths = (resource, componentName) => {
185
230
  tags: [resource],
186
231
  },
187
232
  patch: {
188
- operationId: `patch${resourceName}ById`,
233
+ operationId: operationIds.update,
189
234
  parameters: [createParameterReference('Id')],
190
235
  requestBody: createRequestBody(`${componentName}Update`),
191
236
  responses: {
@@ -196,7 +241,7 @@ const createResourcePaths = (resource, componentName) => {
196
241
  tags: [resource],
197
242
  },
198
243
  put: {
199
- operationId: `put${resourceName}ById`,
244
+ operationId: operationIds.replace,
200
245
  parameters: [createParameterReference('Id')],
201
246
  requestBody: createRequestBody(`${componentName}Create`),
202
247
  responses: {
@@ -210,14 +255,16 @@ const createResourcePaths = (resource, componentName) => {
210
255
  };
211
256
  };
212
257
 
213
- const getOperationIds = (resource) => {
214
- const resourceName = toPascalCase(resource);
215
-
216
- return [`get${resourceName}`, `post${resourceName}`, `delete${resourceName}ById`, `get${resourceName}ById`, `patch${resourceName}ById`, `put${resourceName}ById`];
217
- };
218
-
219
258
  const validateGeneratedNames = (resources, componentNames, files) => {
220
- const schemaOwners = new Map([['Error', 'встроенная схема ошибки'], ...(files ? [['UploadedFile', 'встроенная схема файла']] : [])]);
259
+ const schemaOwners = new Map([
260
+ ['Error', 'встроенная схема ошибки'],
261
+ ...(files
262
+ ? [
263
+ ['FileMetadata', 'встроенная схема метаданных файла'],
264
+ ['FileUpdate', 'встроенная схема изменения файла'],
265
+ ]
266
+ : []),
267
+ ]);
221
268
  const operationOwners = new Map();
222
269
 
223
270
  resources.forEach((resource) => {
@@ -237,7 +284,7 @@ const validateGeneratedNames = (resources, componentNames, files) => {
237
284
  schemaOwners.set(schemaName, resource);
238
285
  });
239
286
 
240
- getOperationIds(resource).forEach((operationId) => {
287
+ Object.values(createResourceOperationIds(resource)).forEach((operationId) => {
241
288
  const owner = operationOwners.get(operationId);
242
289
 
243
290
  if (owner != null) {
@@ -250,15 +297,23 @@ const validateGeneratedNames = (resources, componentNames, files) => {
250
297
  };
251
298
 
252
299
  /**
253
- * Creates an OpenAPI 3.0 document from a database and optional schema configuration.
254
- * @param {Record<string, Array<Record<string, unknown>>>} database Database contents.
255
- * @param {Record<string, unknown>} [schemaConfig] Schema configuration.
256
- * @param {{ files?: boolean, host?: string, port?: number }} [serverOptions] Server address and optional features used in the generated document.
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.
257
302
  * @returns {Record<string, unknown>} OpenAPI document.
258
303
  */
259
- export function createOpenApiDocument(database, schemaConfig = {}, { files = false, host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
304
+ export function buildOpenapiDocument(options) {
305
+ const { database, files = false, maxPageSize = DEFAULT_MAX_PAGE_SIZE, schema: schemaConfig = {} } = options ?? {};
306
+
307
+ if (typeof files !== 'boolean') {
308
+ throw new Error('Ключ files должен содержать boolean');
309
+ }
310
+
260
311
  validateDatabase(database);
261
312
 
313
+ if (!Number.isInteger(maxPageSize) || maxPageSize < 1) {
314
+ throw new Error('Максимальный размер страницы должен быть положительным целым числом');
315
+ }
316
+
262
317
  if (!isObject(schemaConfig)) {
263
318
  throw new Error('Схема базы данных должна содержать JSON-объект');
264
319
  }
@@ -288,9 +343,23 @@ export function createOpenApiDocument(database, schemaConfig = {}, { files = fal
288
343
  const schemas = {
289
344
  Error: { properties: { error: { type: 'string' } }, required: ['error'], type: 'object' },
290
345
  ...(files && {
291
- UploadedFile: {
292
- properties: { id: { type: 'string' }, mimeType: { type: 'string' }, name: { type: 'string' }, size: { minimum: 0, type: 'integer' }, url: { type: 'string' } },
293
- 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' } },
294
363
  type: 'object',
295
364
  },
296
365
  }),
@@ -306,14 +375,9 @@ export function createOpenApiDocument(database, schemaConfig = {}, { files = fal
306
375
  schemas[`${componentName}Page`] = {
307
376
  properties: {
308
377
  data: { items: createSchemaReference(componentName), type: 'array' },
309
- first: { type: 'integer' },
310
- items: { type: 'integer' },
311
- last: { type: 'integer' },
312
- next: { nullable: true, type: 'integer' },
313
- pages: { type: 'integer' },
314
- prev: { nullable: true, type: 'integer' },
378
+ total: { minimum: 0, type: 'integer' },
315
379
  },
316
- required: ['data', 'first', 'items', 'last', 'next', 'pages', 'prev'],
380
+ required: ['data', 'total'],
317
381
  type: 'object',
318
382
  };
319
383
  });
@@ -321,11 +385,10 @@ export function createOpenApiDocument(database, schemaConfig = {}, { files = fal
321
385
  addReverseRelations(schemas, rawSchemas, resources, componentNames);
322
386
 
323
387
  return {
324
- components: { parameters: createParameters(), schemas },
388
+ components: { parameters: createParameters(maxPageSize), schemas },
325
389
  info: isObject(schemaConfig.$info) ? schemaConfig.$info : { title: 'Deep JSON Server API', version: '1.0.0' },
326
390
  openapi: '3.0.3',
327
391
  paths: Object.assign({}, ...resources.map((resource) => createResourcePaths(resource, componentNames[resource])), files ? createFilePaths() : {}),
328
- servers: [{ url: getServerUrl(host, port) }],
329
392
  tags: [...resources.map((resource) => ({ name: resource })), ...(files ? [{ name: 'files' }] : [])],
330
393
  };
331
394
  }
@@ -1,25 +1,36 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { stringify } from 'yaml';
4
- import { readDatabaseFile } from '../database.js';
5
- import { readSchemaConfig } from './config.js';
6
- import { createOpenApiDocument } from './document.js';
7
-
8
- export { createOpenApiDocument } from './document.js';
9
-
10
- /**
11
- * Generates an OpenAPI YAML file.
12
- * @param {{ databasePath: string, files?: boolean, host?: string, outputPath: string, port?: number, schemaPath?: string }} options Generation options.
13
- * @returns {Promise<Record<string, unknown>>} Generated OpenAPI document.
14
- */
15
- export async function generateOpenApi({ databasePath, files = false, host, outputPath, port, schemaPath }) {
16
- const database = await readDatabaseFile(databasePath);
17
- const schemaConfig = await readSchemaConfig(schemaPath);
18
- const document = createOpenApiDocument(database, schemaConfig, { files, host, port });
4
+ import { DEFAULT_HOST, DEFAULT_PORT } from '../constants.js';
5
+ import { buildOpenapiDocument } from './document.js';
6
+
7
+ const getServerUrl = (host, port) => {
8
+ const serverPort = Number(port);
9
+
10
+ if (typeof host !== 'string' || host === '') {
11
+ throw new Error('Адрес сервера не должен быть пустым');
12
+ }
13
+
14
+ if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65_535) {
15
+ throw new Error('Порт должен быть целым числом от 1 до 65535');
16
+ }
17
+
18
+ const serverHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
19
+
20
+ return `http://${serverHost}:${serverPort}`;
21
+ };
22
+
23
+ export const createOpenapi = ({ database, files, host = DEFAULT_HOST, maxPageSize, port = DEFAULT_PORT, schema }) => {
24
+ const document = buildOpenapiDocument({ database, files, maxPageSize, schema });
25
+
26
+ document.servers = [{ url: getServerUrl(host, port) }];
27
+
28
+ return document;
29
+ };
30
+
31
+ export const writeOpenapi = async (document, outputPath) => {
19
32
  const resolvedOutputPath = resolve(outputPath);
20
33
 
21
34
  await mkdir(dirname(resolvedOutputPath), { recursive: true });
22
35
  await writeFile(resolvedOutputPath, stringify(document, { aliasDuplicateObjects: false, lineWidth: 0 }), 'utf8');
23
-
24
- return document;
25
- }
36
+ };
@@ -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
 
@@ -35,16 +35,10 @@ export const parsePagination = (query, maxPageSize = MAX_PAGE_SIZE) => {
35
35
  };
36
36
 
37
37
  export const paginateItems = (items, page, pageSize) => {
38
- const pages = Math.max(1, Math.ceil(items.length / pageSize));
39
38
  const offset = (page - 1) * pageSize;
40
39
 
41
40
  return {
42
41
  data: items.slice(offset, offset + pageSize),
43
- first: 1,
44
- items: items.length,
45
- last: pages,
46
- next: page < pages ? page + 1 : null,
47
- pages,
48
- prev: page > 1 ? Math.min(page - 1, pages) : null,
42
+ total: items.length,
49
43
  };
50
44
  };
@@ -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) {