json-rest-api 1.0.23 → 1.0.24

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.
Files changed (60) hide show
  1. package/README.md +22 -1
  2. package/docs/API.md +145 -24
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +83 -4
  4. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  5. package/lib/enhanced-logger.js +1 -1
  6. package/package.json +6 -2
  7. package/plugins/core/bulk-operations-plugin.js +26 -34
  8. package/plugins/core/connectors/express-plugin.js +0 -6
  9. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  10. package/plugins/core/lib/anyapi/schema-utils.js +43 -4
  11. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -1
  12. package/plugins/core/lib/dbIntrospection.js +1 -1
  13. package/plugins/core/lib/dbTablesOperations.js +10 -3
  14. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  15. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +0 -1
  16. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  17. package/plugins/core/lib/querying/knex-relationship-includes.js +11 -18
  18. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  19. package/plugins/core/lib/querying-writing/knex-field-helpers.js +1 -2
  20. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  21. package/plugins/core/lib/querying-writing/schema-helpers.js +0 -2
  22. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  23. package/plugins/core/lib/storage/canonical-storage-mapping.js +22 -2
  24. package/plugins/core/lib/storage/storage-adapter.js +3 -2
  25. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  26. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -2
  27. package/plugins/core/rest-api-anyapi-knex-plugin.js +194 -102
  28. package/plugins/core/rest-api-knex-plugin.js +23 -16
  29. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +1 -0
  30. package/plugins/core/rest-api-plugin-methods/common.js +27 -9
  31. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +11 -2
  32. package/plugins/core/rest-api-plugin-methods/delete.js +6 -1
  33. package/plugins/core/rest-api-plugin-methods/get-related.js +7 -2
  34. package/plugins/core/rest-api-plugin-methods/get-relationship.js +7 -2
  35. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  36. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +10 -3
  37. package/plugins/core/rest-api-plugin-methods/patch.js +31 -3
  38. package/plugins/core/rest-api-plugin-methods/post-relationship.js +11 -2
  39. package/plugins/core/rest-api-plugin-methods/post.js +8 -0
  40. package/plugins/core/rest-api-plugin-methods/put.js +31 -3
  41. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  42. package/plugins/core/rest-api-plugin.js +4 -0
  43. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  44. package/plugins/core/socketio-plugin.js +0 -1
  45. package/plugins/storage/local-storage.js +1 -1
  46. package/plugins/storage/s3-storage.js +5 -2
  47. package/quickTest.js +1 -0
  48. package/scripts/run-docs.js +80 -84
  49. package/test-debug.js +1 -1
  50. package/tests/README.md +7 -0
  51. package/tests/anyapi-basic.test.js +3 -0
  52. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  53. package/tests/db-introspection.test.js +4 -1
  54. package/tests/db-table-operations.test.js +25 -20
  55. package/tests/fastify-plugin.test.js +21 -6
  56. package/tests/fixtures/api-configs.js +133 -0
  57. package/tests/id-normalization.test.js +444 -0
  58. package/tests/logical-id-write-validation.test.js +151 -0
  59. package/tests/pagination-cursor-multifield.test.js +11 -11
  60. package/tests/storage-mapping.test.js +55 -6
@@ -0,0 +1,151 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import { Api } from 'hooked-api'
5
+ import { RestApiPlugin } from '../plugins/core/rest-api-plugin.js'
6
+ import { RestApiKnexPlugin } from '../plugins/core/rest-api-knex-plugin.js'
7
+ import {
8
+ cleanTables,
9
+ validateJsonApiStructure,
10
+ } from './helpers/test-utils.js'
11
+
12
+ const knex = knexLib({
13
+ client: 'better-sqlite3',
14
+ connection: {
15
+ filename: ':memory:'
16
+ },
17
+ useNullAsDefault: true
18
+ })
19
+
20
+ let api
21
+
22
+ describe('Logical resource ids in write validation', () => {
23
+ before(async () => {
24
+ api = new Api({
25
+ name: 'logical-id-write-validation-test'
26
+ })
27
+
28
+ await api.use(RestApiPlugin, {
29
+ simplifiedApi: false,
30
+ simplifiedTransport: false,
31
+ returnRecordApi: {
32
+ post: 'full',
33
+ put: 'full',
34
+ patch: 'full'
35
+ }
36
+ })
37
+ await api.use(RestApiKnexPlugin, { knex })
38
+
39
+ await api.addResource('profiles', {
40
+ tableName: 'logical_id_profiles',
41
+ idProperty: 'user_id',
42
+ schema: {
43
+ id: { type: 'id', required: true, storage: { column: 'user_id' } },
44
+ name: { type: 'string', required: true, max: 64 }
45
+ }
46
+ })
47
+ await api.resources.profiles.createKnexTable()
48
+ })
49
+
50
+ after(async () => {
51
+ await knex.destroy()
52
+ })
53
+
54
+ beforeEach(async () => {
55
+ await cleanTables(knex, ['logical_id_profiles'])
56
+ })
57
+
58
+ it('accepts explicit resource ids in non-simplified POST without exposing id as an attribute', async () => {
59
+ const created = await api.resources.profiles.post({
60
+ inputRecord: {
61
+ data: {
62
+ type: 'profiles',
63
+ id: '101',
64
+ attributes: {
65
+ name: 'Alice'
66
+ }
67
+ }
68
+ }
69
+ })
70
+
71
+ validateJsonApiStructure(created)
72
+ assert.equal(created.data.id, '101')
73
+ assert.equal(created.data.attributes.name, 'Alice')
74
+ assert.ok(!('id' in created.data.attributes))
75
+
76
+ const dbRow = await knex('logical_id_profiles').where('user_id', 101).first()
77
+ assert.ok(dbRow)
78
+ assert.equal(String(dbRow.user_id), '101')
79
+ assert.equal(dbRow.name, 'Alice')
80
+ })
81
+
82
+ it('reports missing logical ids against data.id during POST validation', async () => {
83
+ await assert.rejects(
84
+ () => api.resources.profiles.post({
85
+ inputRecord: {
86
+ data: {
87
+ type: 'profiles',
88
+ attributes: {
89
+ name: 'Missing Id'
90
+ }
91
+ }
92
+ }
93
+ }),
94
+ (error) => {
95
+ assert.equal(error.code, 'REST_API_VALIDATION')
96
+ assert.deepEqual(error.details?.fields, ['data.id'])
97
+ assert.equal(error.details?.violations?.[0]?.field, 'data.id')
98
+ return true
99
+ }
100
+ )
101
+ })
102
+
103
+ it('accepts explicit resource ids in simplified POST and still keeps id out of attributes', async () => {
104
+ const created = await api.resources.profiles.post({
105
+ id: '202',
106
+ name: 'Bob'
107
+ })
108
+
109
+ assert.equal(created.id, '202')
110
+ assert.equal(created.name, 'Bob')
111
+
112
+ const fetched = await api.resources.profiles.get({
113
+ id: '202',
114
+ simplified: false
115
+ })
116
+
117
+ validateJsonApiStructure(fetched)
118
+ assert.equal(fetched.data.id, '202')
119
+ assert.equal(fetched.data.attributes.name, 'Bob')
120
+ assert.ok(!('id' in fetched.data.attributes))
121
+ })
122
+
123
+ it('accepts logical id validation through PUT full replacement', async () => {
124
+ await knex('logical_id_profiles').insert({
125
+ user_id: 303,
126
+ name: 'Before'
127
+ })
128
+
129
+ const updated = await api.resources.profiles.put({
130
+ id: '303',
131
+ inputRecord: {
132
+ data: {
133
+ type: 'profiles',
134
+ id: '303',
135
+ attributes: {
136
+ name: 'After'
137
+ }
138
+ }
139
+ }
140
+ })
141
+
142
+ validateJsonApiStructure(updated)
143
+ assert.equal(updated.data.id, '303')
144
+ assert.equal(updated.data.attributes.name, 'After')
145
+ assert.ok(!('id' in updated.data.attributes))
146
+
147
+ const dbRow = await knex('logical_id_profiles').where('user_id', 303).first()
148
+ assert.ok(dbRow)
149
+ assert.equal(dbRow.name, 'After')
150
+ })
151
+ })
@@ -55,7 +55,7 @@ describe('Multi-field Cursor Pagination', () => {
55
55
  }
56
56
  })
57
57
 
58
- it.skip('should handle cursor pagination with multi-field sorting without skipping records', async () => {
58
+ it('should handle cursor pagination with multi-field sorting without skipping records', async () => {
59
59
  // Sort by category first (which has duplicates), then by name
60
60
  const pageSize = 3
61
61
  const allProducts = []
@@ -102,7 +102,7 @@ describe('Multi-field Cursor Pagination', () => {
102
102
  assert.equal(ids.length, uniqueIds.length, 'Should have no duplicate products')
103
103
  })
104
104
 
105
- it.skip('should handle DESC sorting with duplicates correctly', async () => {
105
+ it('should handle DESC sorting with duplicates correctly', async () => {
106
106
  // Sort by category DESC, then name ASC
107
107
  const firstPage = await api.resources.products.query({
108
108
  queryParams: {
@@ -189,7 +189,7 @@ describe('Multi-field Cursor Pagination', () => {
189
189
  'Green Gardens vegetables should be in alphabetical order by name')
190
190
  })
191
191
 
192
- it.skip('should handle cursor pagination with numeric and text fields', async () => {
192
+ it('should handle cursor pagination with numeric and text fields', async () => {
193
193
  // Sort by price (numeric), then category (text), then name (text)
194
194
  const firstPage = await api.resources.products.query({
195
195
  queryParams: {
@@ -222,7 +222,7 @@ describe('Multi-field Cursor Pagination', () => {
222
222
  'Prices should be non-decreasing across pages')
223
223
  })
224
224
 
225
- it.skip('should generate correct cursor values for multi-field sorting', async () => {
225
+ it('should generate correct cursor values for multi-field sorting', async () => {
226
226
  const result = await api.resources.products.query({
227
227
  queryParams: {
228
228
  page: { size: 3 },
@@ -244,7 +244,7 @@ describe('Multi-field Cursor Pagination', () => {
244
244
  assert(cursor.includes('name:'), 'Cursor should include name field')
245
245
  })
246
246
 
247
- it.skip('should handle page[before] with multi-field sorting', async () => {
247
+ it('should handle page[before] with multi-field sorting', async () => {
248
248
  // First, get to the middle of the dataset
249
249
  const firstPage = await api.resources.products.query({
250
250
  queryParams: {
@@ -272,7 +272,7 @@ describe('Multi-field Cursor Pagination', () => {
272
272
  size: 4,
273
273
  before: secondPage.meta.pagination.cursor?.next ||
274
274
  // If no next cursor, create one from the last record
275
- `category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)}`
275
+ `category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)},id:${encodeURIComponent(secondPage.data[0].id)}`
276
276
  },
277
277
  sort: ['category', 'name']
278
278
  },
@@ -292,7 +292,7 @@ describe('Multi-field Cursor Pagination', () => {
292
292
  await cleanTables(knex, ['cursor_products', 'cursor_items'])
293
293
  })
294
294
 
295
- it.skip('should handle all records having the same value in first sort field', async () => {
295
+ it('should handle all records having the same value in first sort field', async () => {
296
296
  // Create products all in the same category
297
297
  const products = [
298
298
  { name: 'Alpha', category: 'Electronics', brand: 'TechCo', price: 100, sku: 'TC-001' },
@@ -348,7 +348,7 @@ describe('Multi-field Cursor Pagination', () => {
348
348
  assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo')
349
349
  })
350
350
 
351
- it.skip('should handle empty results with multi-field cursor', async () => {
351
+ it('should handle empty results with multi-field cursor', async () => {
352
352
  // Create one product
353
353
  await api.resources.products.post({
354
354
  inputRecord: createJsonApiDocument('products', {
@@ -375,7 +375,7 @@ describe('Multi-field Cursor Pagination', () => {
375
375
  assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page')
376
376
  })
377
377
 
378
- it.skip('should handle special characters in multi-field cursors', async () => {
378
+ it('should handle special characters in multi-field cursors', async () => {
379
379
  // Create products with special characters
380
380
  const products = [
381
381
  { name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
@@ -418,7 +418,7 @@ describe('Multi-field Cursor Pagination', () => {
418
418
  'Should get different record on second page')
419
419
  })
420
420
 
421
- it.skip('should maintain stable sorting with custom id property', async () => {
421
+ it('should maintain stable sorting with custom id property', async () => {
422
422
  // Create items using the resource with custom ID property
423
423
  const items = [
424
424
  { code: 'A001', name: 'Apple', category: 'Fruit', type: 'Fresh' },
@@ -464,7 +464,7 @@ describe('Multi-field Cursor Pagination', () => {
464
464
  }
465
465
  })
466
466
 
467
- it.skip('should handle the exact bug scenario - multiple fruits with same category', async () => {
467
+ it('should handle the exact bug scenario - multiple fruits with same category', async () => {
468
468
  // This is the exact scenario from the bug report
469
469
  await cleanTables(knex, ['cursor_products'])
470
470
 
@@ -70,13 +70,12 @@ describe('Storage mapping', () => {
70
70
  await api.addResource('profiles', {
71
71
  schema: {
72
72
  id: { type: 'id' },
73
- displayName: { type: 'string', required: true, storage: { column: 'display_name' } },
74
- loginCount: { type: 'number', defaultTo: 0, storage: { column: 'login_count' } },
73
+ displayName: { type: 'string', required: true, search: true },
74
+ loginCount: { type: 'number', defaultTo: 0 },
75
75
  lastSeenAt: {
76
76
  type: 'dateTime',
77
77
  nullable: true,
78
78
  storage: {
79
- column: 'last_seen_at',
80
79
  serialize: serializeDateTimeForSql
81
80
  }
82
81
  },
@@ -84,14 +83,25 @@ describe('Storage mapping', () => {
84
83
  type: 'id',
85
84
  nullable: true,
86
85
  belongsTo: 'countries',
87
- as: 'country',
88
- storage: { column: 'country_id' }
86
+ as: 'country'
89
87
  },
88
+ externalRef: { type: 'string', nullable: true, search: true, storage: { column: 'legacy_ref' } },
90
89
  confirmationToken: { type: 'string', virtual: true }
91
90
  },
92
91
  tableName: 'mapped_profiles'
93
92
  })
94
93
  await api.resources.profiles.createKnexTable()
94
+
95
+ await api.addResource('verbatim_profiles', {
96
+ storage: { naming: 'exact' },
97
+ schema: {
98
+ id: { type: 'id' },
99
+ displayName: { type: 'string', required: true },
100
+ loginCount: { type: 'number', defaultTo: 0 }
101
+ },
102
+ tableName: 'verbatim_profiles'
103
+ })
104
+ await api.resources.verbatim_profiles.createKnexTable()
95
105
  })
96
106
 
97
107
  after(async () => {
@@ -99,7 +109,7 @@ describe('Storage mapping', () => {
99
109
  })
100
110
 
101
111
  beforeEach(async () => {
102
- await cleanTables(knex, ['mapped_profiles', 'mapped_countries'])
112
+ await cleanTables(knex, ['mapped_profiles', 'mapped_countries', 'verbatim_profiles'])
103
113
  })
104
114
 
105
115
  it('persists mapped columns while exposing logical field names', async () => {
@@ -116,6 +126,7 @@ describe('Storage mapping', () => {
116
126
  displayName: 'Mercury',
117
127
  loginCount: 7,
118
128
  lastSeenAt: '2026-01-02T03:04:05Z',
129
+ externalRef: 'legacy-001',
119
130
  confirmationToken: 'secret-token'
120
131
  },
121
132
  {
@@ -140,6 +151,7 @@ describe('Storage mapping', () => {
140
151
  assert.equal(dbRecord.login_count, 7)
141
152
  assert.equal(dbRecord.last_seen_at, '2026-01-02 03:04:05')
142
153
  assert.equal(String(dbRecord.country_id), country.data.id)
154
+ assert.equal(dbRecord.legacy_ref, 'legacy-001')
143
155
  assert.ok(!('displayName' in dbRecord))
144
156
  assert.ok(!('countryId' in dbRecord))
145
157
  assert.ok(!('confirmationToken' in dbRecord))
@@ -164,6 +176,22 @@ describe('Storage mapping', () => {
164
176
  'country',
165
177
  resourceIdentifier('countries', country.data.id)
166
178
  )
179
+
180
+ const queryResult = await api.resources.profiles.query({
181
+ queryParams: {
182
+ filters: {
183
+ displayName: 'Mercury',
184
+ externalRef: 'legacy-001'
185
+ }
186
+ }
187
+ })
188
+
189
+ validateJsonApiStructure(queryResult, true)
190
+ assert.equal(queryResult.data.length, 1)
191
+ assertResourceAttributes(queryResult.data[0], {
192
+ displayName: 'Mercury',
193
+ externalRef: 'legacy-001'
194
+ })
167
195
  })
168
196
 
169
197
  it('applies storage mapping on PATCH updates and sparse fieldsets', async () => {
@@ -207,4 +235,25 @@ describe('Storage mapping', () => {
207
235
  assert.equal(dbRecord.last_seen_at, '2026-02-03 04:05:06')
208
236
  assert.ok(!('displayName' in dbRecord))
209
237
  })
238
+
239
+ it('can opt out of snake_case translation for an entire resource', async () => {
240
+ const created = await api.resources.verbatim_profiles.post({
241
+ inputRecord: createJsonApiDocument('verbatim_profiles', {
242
+ displayName: 'Exact Mode',
243
+ loginCount: 3
244
+ })
245
+ })
246
+
247
+ validateJsonApiStructure(created)
248
+ assertResourceAttributes(created.data, {
249
+ displayName: 'Exact Mode',
250
+ loginCount: 3
251
+ })
252
+
253
+ const dbRecord = await knex('verbatim_profiles').where('id', created.data.id).first()
254
+ assert.equal(dbRecord.displayName, 'Exact Mode')
255
+ assert.equal(dbRecord.loginCount, 3)
256
+ assert.equal(Object.hasOwn(dbRecord, 'display_name'), false)
257
+ assert.equal(Object.hasOwn(dbRecord, 'login_count'), false)
258
+ })
210
259
  })