json-rest-api 1.0.24 → 1.0.25

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 (82) hide show
  1. package/README.md +2 -1
  2. package/docs/API.md +12 -9
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  10. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  11. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  12. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  13. package/docs/GUIDE/index.md +4 -1
  14. package/fixing_plan.md +112 -0
  15. package/index.js +2 -0
  16. package/package.json +1 -1
  17. package/plugins/core/bulk-operations-plugin.js +5 -4
  18. package/plugins/core/connectors/express-plugin.js +6 -1
  19. package/plugins/core/connectors/fastify-plugin.js +5 -1
  20. package/plugins/core/connectors/lib/connector-core.js +17 -11
  21. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  22. package/plugins/core/file-handling-plugin.js +126 -96
  23. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  24. package/plugins/core/lib/anyapi/schema-utils.js +1 -0
  25. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  26. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  27. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  28. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  29. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  30. package/plugins/core/lib/querying/url-helpers.js +9 -21
  31. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  32. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  33. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  34. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  35. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  36. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  37. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  38. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  39. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  40. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  41. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  42. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  43. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  44. package/plugins/core/rest-api-knex-plugin.js +70 -60
  45. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  46. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  47. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  48. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  49. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  50. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  51. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  52. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  53. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  54. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  55. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  56. package/plugins/core/rest-api-plugin.js +6 -36
  57. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  58. package/plugins/storage/local-storage.js +44 -19
  59. package/plugins/storage/s3-storage.js +21 -55
  60. package/tests/README.md +1 -1
  61. package/tests/anyapi-cursor-pagination.test.js +7 -1
  62. package/tests/anyapi-custom-idproperty.test.js +163 -0
  63. package/tests/fastify-plugin.test.js +28 -0
  64. package/tests/field-setters.test.js +10 -10
  65. package/tests/file-handling.test.js +118 -0
  66. package/tests/fixtures/api-configs.js +362 -4
  67. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  68. package/tests/http-connectors-parity.test.js +165 -5
  69. package/tests/local-storage.test.js +96 -0
  70. package/tests/pagination-cursor-multifield.test.js +121 -0
  71. package/tests/pagination-enhanced.test.js +66 -15
  72. package/tests/post-commit-hooks.test.js +120 -0
  73. package/tests/public-exports.test.js +15 -0
  74. package/tests/put-create-parity.test.js +216 -0
  75. package/tests/relationship-endpoints.test.js +259 -0
  76. package/tests/return-record-settings.test.js +184 -0
  77. package/tests/row-policy.test.js +433 -0
  78. package/tests/s3-storage.test.js +38 -0
  79. package/tests/searchschema-merge.test.js +17 -0
  80. package/tests/socketio.test.js +62 -0
  81. package/tests/storage-mapping.test.js +1 -0
  82. package/tests/test-development-guide.md +5 -5
@@ -9,11 +9,17 @@ import {
9
9
  resourceIdentifier,
10
10
  } from './helpers/test-utils.js'
11
11
  import { storageMode } from './helpers/storage-mode.js'
12
+ import { parseJsonApiQuery } from '../plugins/core/lib/querying-writing/connectors-query-parser.js'
12
13
 
13
14
  const isAnyApi = storageMode.isAnyApi()
14
15
 
15
16
  const maybeDescribe = isAnyApi ? describe : describe.skip
16
17
 
18
+ const parseLinkQuery = (link) => {
19
+ const url = new URL(link, 'https://example.test')
20
+ return parseJsonApiQuery(url.search.slice(1))
21
+ }
22
+
17
23
  maybeDescribe('AnyAPI Cursor Pagination', () => {
18
24
  const knex = knexLib({
19
25
  client: 'better-sqlite3',
@@ -78,7 +84,7 @@ maybeDescribe('AnyAPI Cursor Pagination', () => {
78
84
 
79
85
  assert.equal(result.data.length, 2)
80
86
  assert(result.meta?.pagination?.cursor?.next, 'Should include next cursor')
81
- assert(result.links?.next?.includes('page[after]='), 'Next link should include cursor parameter')
87
+ assert(parseLinkQuery(result.links?.next).page.after, 'Next link should include cursor parameter')
82
88
  })
83
89
 
84
90
  it('paginates forward using page[after]', async () => {
@@ -0,0 +1,163 @@
1
+ import { describe, it, before, beforeEach, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import { Api } from 'hooked-api'
5
+
6
+ import { RestApiPlugin, RestApiAnyapiKnexPlugin } from '../index.js'
7
+ import { ensureAnyApiSchema } from '../plugins/core/lib/anyapi/schema-utils.js'
8
+ import { storageMode } from './helpers/storage-mode.js'
9
+ import {
10
+ createJsonApiDocument,
11
+ createRelationship,
12
+ resourceIdentifier,
13
+ } from './helpers/test-utils.js'
14
+
15
+ const maybeDescribe = storageMode.isAnyApi() ? describe : describe.skip
16
+
17
+ maybeDescribe('AnyAPI custom idProperty', () => {
18
+ const knex = knexLib({
19
+ client: 'better-sqlite3',
20
+ connection: { filename: ':memory:' },
21
+ useNullAsDefault: true,
22
+ })
23
+
24
+ let api
25
+
26
+ before(async () => {
27
+ await ensureAnyApiSchema(knex)
28
+
29
+ api = new Api({ name: 'anyapi-custom-idproperty-test', log: { level: 'warn' } })
30
+ await api.use(RestApiPlugin, {
31
+ simplifiedApi: false,
32
+ simplifiedTransport: false,
33
+ returnRecordApi: {
34
+ post: 'full',
35
+ put: 'full',
36
+ patch: 'full',
37
+ },
38
+ sortableFields: ['id', 'owner_id', 'item_id', 'code', 'name', 'category'],
39
+ queryDefaultLimit: 20,
40
+ queryMaxLimit: 100,
41
+ })
42
+ await api.use(RestApiAnyapiKnexPlugin, { knex })
43
+
44
+ await api.addResource('owners', {
45
+ schema: {
46
+ owner_id: { type: 'id' },
47
+ name: { type: 'string', required: true },
48
+ },
49
+ idProperty: 'owner_id',
50
+ tableName: 'custom_id_owners',
51
+ })
52
+ await api.resources.owners.createKnexTable()
53
+
54
+ await api.addResource('items', {
55
+ schema: {
56
+ item_id: { type: 'id' },
57
+ code: { type: 'string', required: true, search: true },
58
+ name: { type: 'string', required: true },
59
+ category: { type: 'string', required: true },
60
+ owner_id: { type: 'id', belongsTo: 'owners', as: 'owner' },
61
+ },
62
+ idProperty: 'item_id',
63
+ tableName: 'custom_id_items',
64
+ })
65
+ await api.resources.items.createKnexTable()
66
+ })
67
+
68
+ after(async () => {
69
+ await api?.release()
70
+ await knex.destroy()
71
+ })
72
+
73
+ beforeEach(async () => {
74
+ await knex('any_links').delete()
75
+ await knex('any_records').delete()
76
+ })
77
+
78
+ it('uses logical ids for post, get, query, sparse fields, cursors, and includes', async () => {
79
+ const owner = await api.resources.owners.post({
80
+ inputRecord: createJsonApiDocument('owners', { name: 'Owner One' }),
81
+ simplified: false,
82
+ })
83
+
84
+ const itemInputs = [
85
+ { code: 'A001', name: 'Apple', category: 'Fruit' },
86
+ { code: 'A002', name: 'Apricot', category: 'Fruit' },
87
+ { code: 'B001', name: 'Broccoli', category: 'Vegetable' },
88
+ { code: 'C001', name: 'Carrot', category: 'Vegetable' },
89
+ ]
90
+
91
+ const createdItems = []
92
+ for (const item of itemInputs) {
93
+ const created = await api.resources.items.post({
94
+ inputRecord: createJsonApiDocument(
95
+ 'items',
96
+ item,
97
+ { owner: createRelationship(resourceIdentifier('owners', owner.data.id)) }
98
+ ),
99
+ simplified: false,
100
+ })
101
+ createdItems.push(created)
102
+ }
103
+
104
+ const fetched = await api.resources.items.get({
105
+ id: createdItems[0].data.id,
106
+ queryParams: {
107
+ include: ['owner'],
108
+ fields: {
109
+ items: 'item_id,code,name,category',
110
+ owners: 'owner_id,name',
111
+ },
112
+ },
113
+ simplified: false,
114
+ })
115
+
116
+ assert.equal(fetched.data.id, createdItems[0].data.id)
117
+ assert.equal(fetched.data.attributes.code, 'A001')
118
+ assert.equal(fetched.data.attributes.name, 'Apple')
119
+ assert.equal(fetched.data.attributes.item_id, undefined)
120
+ assert.equal(fetched.included?.[0]?.type, 'owners')
121
+ assert.equal(fetched.included?.[0]?.id, owner.data.id)
122
+ assert.equal(fetched.included?.[0]?.attributes.owner_id, undefined)
123
+
124
+ const firstPage = await api.resources.items.query({
125
+ queryParams: {
126
+ include: ['owner'],
127
+ fields: {
128
+ items: 'item_id,code,name,category',
129
+ owners: 'owner_id,name',
130
+ },
131
+ sort: ['category', 'name'],
132
+ page: { size: 2 },
133
+ },
134
+ simplified: false,
135
+ })
136
+
137
+ assert.equal(firstPage.data.length, 2)
138
+ assert(firstPage.meta.pagination.cursor.next, 'first page should include a next cursor')
139
+ assert(firstPage.data.every((item) => item.attributes.item_id === undefined))
140
+ assert(firstPage.included?.some((entry) => entry.type === 'owners' && entry.id === owner.data.id))
141
+
142
+ const secondPage = await api.resources.items.query({
143
+ queryParams: {
144
+ include: ['owner'],
145
+ fields: {
146
+ items: 'item_id,code,name,category',
147
+ owners: 'owner_id,name',
148
+ },
149
+ sort: ['category', 'name'],
150
+ page: {
151
+ size: 2,
152
+ after: firstPage.meta.pagination.cursor.next,
153
+ },
154
+ },
155
+ simplified: false,
156
+ })
157
+
158
+ const firstIds = firstPage.data.map((item) => item.id)
159
+ const secondIds = secondPage.data.map((item) => item.id)
160
+ assert.equal(secondPage.data.length, 2)
161
+ assert(!secondIds.some((id) => firstIds.includes(id)), 'cursor page should not repeat records')
162
+ })
163
+ })
@@ -2,6 +2,7 @@ import { describe, it, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { Api } from 'hooked-api'
4
4
  import { RestApiPlugin, FastifyPlugin } from '../index.js'
5
+ import { getRequestContracts } from '../plugins/core/lib/querying-writing/request-contracts.js'
5
6
  import {
6
7
  FakeFastifyApp,
7
8
  FakeReply,
@@ -75,6 +76,12 @@ describe('Fastify Plugin', () => {
75
76
  const postRelationshipRoute = findFastifyRoute(app, 'POST', '/api/articles/:id/relationships/:relationshipName')
76
77
  const patchRelationshipRoute = findFastifyRoute(app, 'PATCH', '/api/articles/:id/relationships/:relationshipName')
77
78
  const deleteRelationshipRoute = findFastifyRoute(app, 'DELETE', '/api/articles/:id/relationships/:relationshipName')
79
+ const relationshipContracts = getRequestContracts({
80
+ scopeName: 'articles',
81
+ schemaInfo: api.resources.articles.vars.schemaInfo,
82
+ includeDepthLimit: api.resources.articles.vars.includeDepthLimit,
83
+ sortableFields: api.resources.articles.vars.sortableFields
84
+ })
78
85
 
79
86
  assert.ok(postRoute?.schema?.body, 'POST route should have a body schema')
80
87
  assert.ok(putRoute?.schema?.body, 'PUT route should have a body schema')
@@ -108,6 +115,27 @@ describe('Fastify Plugin', () => {
108
115
  assert.equal(postRelationshipRoute.schema.body.properties.data.type, 'array')
109
116
  assert.ok(Array.isArray(patchRelationshipRoute.schema.body.properties.data.anyOf))
110
117
  assert.equal(deleteRelationshipRoute.schema.body.properties.data.type, 'array')
118
+ assert.deepEqual(
119
+ postRelationshipRoute.schema.body,
120
+ relationshipContracts.postRelationship.schema.toJsonSchema({
121
+ mode: relationshipContracts.postRelationship.mode,
122
+ additionalProperties: false
123
+ })
124
+ )
125
+ assert.deepEqual(
126
+ patchRelationshipRoute.schema.body,
127
+ relationshipContracts.patchRelationship.schema.toJsonSchema({
128
+ mode: relationshipContracts.patchRelationship.mode,
129
+ additionalProperties: false
130
+ })
131
+ )
132
+ assert.deepEqual(
133
+ deleteRelationshipRoute.schema.body,
134
+ relationshipContracts.deleteRelationship.schema.toJsonSchema({
135
+ mode: relationshipContracts.deleteRelationship.mode,
136
+ additionalProperties: false
137
+ })
138
+ )
111
139
  })
112
140
 
113
141
  it('registers the JSON:API content-type parser and rejects unsupported write content types before the route handler', async () => {
@@ -176,7 +176,7 @@ describe('Field Setters', () => {
176
176
  await cleanTables(knex, ['setter_users'])
177
177
  })
178
178
 
179
- it('should handle setter errors gracefully', async () => {
179
+ it('should reject setter errors and roll back the write', async () => {
180
180
  // Create a resource with a failing setter
181
181
  await api.addResource('error_test', {
182
182
  schema: {
@@ -196,16 +196,16 @@ describe('Field Setters', () => {
196
196
  })
197
197
  await api.resources.error_test.createKnexTable()
198
198
 
199
- const record = await api.resources.error_test.post({
200
- good_field: 'HELLO',
201
- bad_field: 'world'
202
- })
199
+ await assert.rejects(
200
+ api.resources.error_test.post({
201
+ good_field: 'HELLO',
202
+ bad_field: 'world'
203
+ }),
204
+ /Setter for field 'bad_field' failed: Setter failed!/
205
+ )
203
206
 
204
- const fetchedRecord = await api.resources.error_test.get({ id: record.id })
205
- // Good field should be transformed
206
- assert.equal(fetchedRecord.good_field, 'hello')
207
- // Bad field should keep validated value (error logged but not thrown)
208
- assert.equal(fetchedRecord.bad_field, 'world')
207
+ const records = await api.resources.error_test.query()
208
+ assert.equal(records?.length || 0, 0)
209
209
 
210
210
  await cleanTables(knex, ['setter_errors'])
211
211
  })
@@ -0,0 +1,118 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import { cleanTables } from './helpers/test-utils.js'
5
+ import { createFileUploadApi } from './fixtures/api-configs.js'
6
+
7
+ const knex = knexLib({
8
+ client: 'better-sqlite3',
9
+ connection: {
10
+ filename: ':memory:'
11
+ },
12
+ useNullAsDefault: true
13
+ })
14
+
15
+ let api
16
+ let detectorState
17
+ let storage
18
+
19
+ function createTrackingStorage () {
20
+ return {
21
+ uploaded: [],
22
+ deleted: [],
23
+
24
+ async upload (file) {
25
+ const url = `/uploads/${file.filename}`
26
+ this.uploaded.push(url)
27
+ return url
28
+ },
29
+
30
+ async delete (url) {
31
+ this.deleted.push(url)
32
+ }
33
+ }
34
+ }
35
+
36
+ function createTestFile ({ filename = 'upload.png', mimetype = 'image/png', cleanup } = {}) {
37
+ return {
38
+ filename,
39
+ mimetype,
40
+ size: 1,
41
+ data: Buffer.from('x'),
42
+ cleanup
43
+ }
44
+ }
45
+
46
+ describe('File handling cleanup', () => {
47
+ before(async () => {
48
+ detectorState = { payload: null }
49
+ storage = createTrackingStorage()
50
+ api = await createFileUploadApi(knex, { detectorState, storage })
51
+ })
52
+
53
+ after(async () => {
54
+ await knex.destroy()
55
+ })
56
+
57
+ beforeEach(async () => {
58
+ await cleanTables(knex, ['file_documents'])
59
+ detectorState.payload = null
60
+ storage.uploaded = []
61
+ storage.deleted = []
62
+ })
63
+
64
+ it('cleans detector temp files when MIME validation rejects an upload', async () => {
65
+ let cleanupCalls = 0
66
+ detectorState.payload = {
67
+ fields: { title: 'Invalid MIME' },
68
+ files: {
69
+ attachment: createTestFile({
70
+ filename: 'invalid.txt',
71
+ mimetype: 'text/plain',
72
+ cleanup: async () => {
73
+ cleanupCalls++
74
+ }
75
+ })
76
+ }
77
+ }
78
+
79
+ await assert.rejects(
80
+ api.resources.documents.post({
81
+ inputRecord: { data: { type: 'documents', attributes: {} } },
82
+ simplified: false
83
+ }),
84
+ /Invalid file type for field 'attachment'/
85
+ )
86
+
87
+ assert.equal(cleanupCalls, 1)
88
+ assert.deepEqual(storage.uploaded, [])
89
+ assert.deepEqual(storage.deleted, [])
90
+ })
91
+
92
+ it('deletes uploaded files when a later write validation error rolls back', async () => {
93
+ let cleanupCalls = 0
94
+ detectorState.payload = {
95
+ fields: {},
96
+ files: {
97
+ attachment: createTestFile({
98
+ filename: 'valid.png',
99
+ cleanup: async () => {
100
+ cleanupCalls++
101
+ }
102
+ })
103
+ }
104
+ }
105
+
106
+ await assert.rejects(
107
+ api.resources.documents.post({
108
+ inputRecord: { data: { type: 'documents', attributes: {} } },
109
+ simplified: false
110
+ }),
111
+ /Schema validation failed for resource attributes/
112
+ )
113
+
114
+ assert.equal(cleanupCalls, 1)
115
+ assert.deepEqual(storage.uploaded, ['/uploads/valid.png'])
116
+ assert.deepEqual(storage.deleted, ['/uploads/valid.png'])
117
+ })
118
+ })