json-rest-api 1.0.21 → 1.0.23
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/CLAUDE.md +1 -2
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
- package/docs/API.md +51 -14
- package/docs/COMPARISON.md +2 -2
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
- package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +250 -0
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
- package/docs/GUIDE/index.md +18 -9
- package/docs/QUICKSTART.md +5 -1
- package/docs/index.md +1 -1
- package/index.js +3 -2
- package/package.json +4 -4
- package/plugins/core/connectors/express-plugin.js +97 -184
- package/plugins/core/connectors/fastify-plugin.js +206 -0
- package/plugins/core/connectors/lib/connector-core.js +195 -0
- package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
- package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
- package/plugins/core/lib/dbIntrospection.js +1078 -0
- package/plugins/core/lib/dbTablesOperations.js +1104 -160
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
- package/plugins/core/lib/querying/knex-process-includes.js +3 -4
- package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
- package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
- package/plugins/core/lib/querying/query-builder-utils.js +19 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
- package/plugins/core/lib/querying/sort-helpers.js +33 -0
- package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
- package/plugins/core/lib/querying/url-helpers.js +8 -2
- package/plugins/core/lib/querying-writing/compile-schemas.js +12 -1
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
- package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +113 -0
- package/plugins/core/lib/storage/storage-adapter.js +79 -2
- package/plugins/core/lib/storage/storage-mapping.js +204 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
- package/plugins/core/rest-api-autofilter-plugin.js +414 -0
- package/plugins/core/rest-api-knex-plugin.js +249 -211
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +121 -83
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
- package/plugins/core/rest-api-plugin-methods/get.js +23 -14
- package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
- package/plugins/core/rest-api-plugin-methods/post.js +13 -8
- package/plugins/core/rest-api-plugin-methods/put.js +45 -21
- package/plugins/core/rest-api-plugin-methods/query.js +15 -53
- package/plugins/core/rest-api-plugin.js +2 -0
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/plugins/core/rest-api-query-projections-plugin.js +130 -0
- package/plugins/core/socketio-plugin.js +1 -3
- package/tests/autofilter.test.js +463 -0
- package/tests/custom-id-simplified-post.test.js +75 -0
- package/tests/custom-idproperty-relationships.test.js +33 -0
- package/tests/db-introspection.test.js +518 -0
- package/tests/db-table-operations.test.js +781 -0
- package/tests/fastify-plugin.test.js +141 -0
- package/tests/field-getters.test.js +1 -2
- package/tests/field-setters.test.js +0 -4
- package/tests/fixtures/api-configs.js +188 -219
- package/tests/helpers/fake-fastify.js +99 -0
- package/tests/http-connectors-parity.test.js +235 -0
- package/tests/projected-fields.test.js +319 -0
- package/tests/relationships.test.js +3 -3
- package/tests/searchschema-knex-behavior.test.js +187 -0
- package/tests/searchschema-merge.test.js +21 -69
- package/tests/storage-mapping.test.js +210 -0
- package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
- package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
- package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
- package/plugins/core/rest-api-access.js +0 -459
- package/plugins/core/rest-api-multihome-plugin.js +0 -314
- package/tests/multihome.test.js +0 -553
- package/tests/multihome.test.js.bak +0 -550
- package/tests/ownership-optout.test.js +0 -76
- package/tests/rest-api-access.test.js +0 -206
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { describe, it, before, after, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import knexLib from 'knex'
|
|
4
|
+
import {
|
|
5
|
+
validateJsonApiStructure,
|
|
6
|
+
cleanTables,
|
|
7
|
+
createJsonApiDocument,
|
|
8
|
+
assertResourceAttributes,
|
|
9
|
+
createRelationship,
|
|
10
|
+
resourceIdentifier
|
|
11
|
+
} from './helpers/test-utils.js'
|
|
12
|
+
import { storageMode } from './helpers/storage-mode.js'
|
|
13
|
+
import { createAutoFilterApi } from './fixtures/api-configs.js'
|
|
14
|
+
|
|
15
|
+
const knex = knexLib({
|
|
16
|
+
client: 'better-sqlite3',
|
|
17
|
+
connection: {
|
|
18
|
+
filename: ':memory:'
|
|
19
|
+
},
|
|
20
|
+
useNullAsDefault: true
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
let api
|
|
24
|
+
|
|
25
|
+
const scopedContext = (workspaceId, userId, projectId) => ({
|
|
26
|
+
scopeValues: {
|
|
27
|
+
workspaceId,
|
|
28
|
+
userId,
|
|
29
|
+
...(projectId !== undefined ? { projectId } : {})
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const moveTaskOutOfScope = async ({ taskId, workspaceId, userId }) => {
|
|
34
|
+
if (storageMode.isAnyApi()) {
|
|
35
|
+
const descriptor = await api.anyapi.registry.getDescriptor(api.anyapi.tenantId, 'tasks')
|
|
36
|
+
await knex('any_records')
|
|
37
|
+
.where({
|
|
38
|
+
id: Number(taskId),
|
|
39
|
+
tenant_id: api.anyapi.tenantId,
|
|
40
|
+
resource: 'tasks'
|
|
41
|
+
})
|
|
42
|
+
.update({
|
|
43
|
+
[descriptor.fields.workspace_id.slot]: workspaceId,
|
|
44
|
+
[descriptor.fields.user_id.slot]: userId
|
|
45
|
+
})
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await knex('autofilter_tasks')
|
|
50
|
+
.where({ id: Number(taskId) })
|
|
51
|
+
.update({
|
|
52
|
+
workspace_id: workspaceId,
|
|
53
|
+
user_id: userId
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('AutoFilter Plugin', () => {
|
|
58
|
+
before(async () => {
|
|
59
|
+
api = await createAutoFilterApi(knex)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
after(async () => {
|
|
63
|
+
await knex.destroy()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
await cleanTables(knex, [
|
|
68
|
+
'autofilter_workspace_reports',
|
|
69
|
+
'autofilter_user_notes',
|
|
70
|
+
'autofilter_projects',
|
|
71
|
+
'autofilter_tasks',
|
|
72
|
+
'autofilter_optional_tasks',
|
|
73
|
+
'autofilter_system_settings'
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('stamps workspace-scoped records and filters collections by workspace', async () => {
|
|
78
|
+
const reportA = await api.resources.workspace_reports.post({
|
|
79
|
+
inputRecord: createJsonApiDocument('workspace_reports', {
|
|
80
|
+
title: 'Workspace A report'
|
|
81
|
+
}),
|
|
82
|
+
simplified: false
|
|
83
|
+
}, scopedContext('workspace-a', 101))
|
|
84
|
+
|
|
85
|
+
await api.resources.workspace_reports.post({
|
|
86
|
+
inputRecord: createJsonApiDocument('workspace_reports', {
|
|
87
|
+
title: 'Workspace B report'
|
|
88
|
+
}),
|
|
89
|
+
simplified: false
|
|
90
|
+
}, scopedContext('workspace-b', 202))
|
|
91
|
+
|
|
92
|
+
validateJsonApiStructure(reportA)
|
|
93
|
+
assertResourceAttributes(reportA.data, {
|
|
94
|
+
title: 'Workspace A report',
|
|
95
|
+
workspace_id: 'workspace-a'
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const queryA = await api.resources.workspace_reports.query({
|
|
99
|
+
simplified: false
|
|
100
|
+
}, scopedContext('workspace-a', 999))
|
|
101
|
+
|
|
102
|
+
validateJsonApiStructure(queryA, true)
|
|
103
|
+
assert.equal(queryA.data.length, 1)
|
|
104
|
+
assert.equal(queryA.data[0].attributes.title, 'Workspace A report')
|
|
105
|
+
assert.equal(queryA.data[0].attributes.workspace_id, 'workspace-a')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('stamps user-scoped records and filters collections by user', async () => {
|
|
109
|
+
const noteA = await api.resources.user_notes.post({
|
|
110
|
+
inputRecord: createJsonApiDocument('user_notes', {
|
|
111
|
+
body: 'User 101 note'
|
|
112
|
+
}),
|
|
113
|
+
simplified: false
|
|
114
|
+
}, scopedContext('workspace-a', 101))
|
|
115
|
+
|
|
116
|
+
await api.resources.user_notes.post({
|
|
117
|
+
inputRecord: createJsonApiDocument('user_notes', {
|
|
118
|
+
body: 'User 202 note'
|
|
119
|
+
}),
|
|
120
|
+
simplified: false
|
|
121
|
+
}, scopedContext('workspace-a', 202))
|
|
122
|
+
|
|
123
|
+
validateJsonApiStructure(noteA)
|
|
124
|
+
assertResourceAttributes(noteA.data, {
|
|
125
|
+
body: 'User 101 note',
|
|
126
|
+
user_id: 101
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const queryA = await api.resources.user_notes.query({
|
|
130
|
+
simplified: false
|
|
131
|
+
}, scopedContext('workspace-b', 101))
|
|
132
|
+
|
|
133
|
+
validateJsonApiStructure(queryA, true)
|
|
134
|
+
assert.equal(queryA.data.length, 1)
|
|
135
|
+
assert.equal(queryA.data[0].attributes.body, 'User 101 note')
|
|
136
|
+
assert.equal(queryA.data[0].attributes.user_id, 101)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('applies composite workspace+user scoping', async () => {
|
|
140
|
+
const ownProject = await api.resources.projects.post({
|
|
141
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
142
|
+
name: 'Workspace A / User 101'
|
|
143
|
+
}),
|
|
144
|
+
simplified: false
|
|
145
|
+
}, scopedContext('workspace-a', 101))
|
|
146
|
+
|
|
147
|
+
await api.resources.projects.post({
|
|
148
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
149
|
+
name: 'Workspace A / User 202'
|
|
150
|
+
}),
|
|
151
|
+
simplified: false
|
|
152
|
+
}, scopedContext('workspace-a', 202))
|
|
153
|
+
|
|
154
|
+
await api.resources.projects.post({
|
|
155
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
156
|
+
name: 'Workspace B / User 101'
|
|
157
|
+
}),
|
|
158
|
+
simplified: false
|
|
159
|
+
}, scopedContext('workspace-b', 101))
|
|
160
|
+
|
|
161
|
+
validateJsonApiStructure(ownProject)
|
|
162
|
+
assertResourceAttributes(ownProject.data, {
|
|
163
|
+
name: 'Workspace A / User 101',
|
|
164
|
+
workspace_id: 'workspace-a',
|
|
165
|
+
user_id: 101
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const scopedQuery = await api.resources.projects.query({
|
|
169
|
+
simplified: false
|
|
170
|
+
}, scopedContext('workspace-a', 101))
|
|
171
|
+
|
|
172
|
+
validateJsonApiStructure(scopedQuery, true)
|
|
173
|
+
assert.equal(scopedQuery.data.length, 1)
|
|
174
|
+
assert.equal(scopedQuery.data[0].attributes.name, 'Workspace A / User 101')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('treats public resources as unscoped', async () => {
|
|
178
|
+
const created = await api.resources.system_settings.post({
|
|
179
|
+
inputRecord: createJsonApiDocument('system_settings', {
|
|
180
|
+
key: 'app.version',
|
|
181
|
+
value: '1.0.0'
|
|
182
|
+
}),
|
|
183
|
+
simplified: false
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
validateJsonApiStructure(created)
|
|
187
|
+
|
|
188
|
+
const anonymousQuery = await api.resources.system_settings.query({
|
|
189
|
+
simplified: false
|
|
190
|
+
})
|
|
191
|
+
validateJsonApiStructure(anonymousQuery, true)
|
|
192
|
+
assert.equal(anonymousQuery.data.length, 1)
|
|
193
|
+
|
|
194
|
+
const scopedQuery = await api.resources.system_settings.query({
|
|
195
|
+
simplified: false
|
|
196
|
+
}, scopedContext('workspace-a', 101))
|
|
197
|
+
validateJsonApiStructure(scopedQuery, true)
|
|
198
|
+
assert.equal(scopedQuery.data.length, 1)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('scopes single-record lookups so out-of-scope records are not found', async () => {
|
|
202
|
+
const project = await api.resources.projects.post({
|
|
203
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
204
|
+
name: 'Scoped Project'
|
|
205
|
+
}),
|
|
206
|
+
simplified: false
|
|
207
|
+
}, scopedContext('workspace-a', 101))
|
|
208
|
+
|
|
209
|
+
await assert.rejects(
|
|
210
|
+
async () => {
|
|
211
|
+
await api.resources.projects.get({
|
|
212
|
+
id: project.data.id,
|
|
213
|
+
simplified: false
|
|
214
|
+
}, scopedContext('workspace-a', 202))
|
|
215
|
+
},
|
|
216
|
+
(error) => error.code === 'REST_API_RESOURCE',
|
|
217
|
+
'Out-of-scope records should behave as not found'
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('stamps scoped fields on PUT and rejects inconsistent scoped updates', async () => {
|
|
222
|
+
const project = await api.resources.projects.post({
|
|
223
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
224
|
+
name: 'Original Project',
|
|
225
|
+
description: 'Original Description'
|
|
226
|
+
}),
|
|
227
|
+
simplified: false
|
|
228
|
+
}, scopedContext('workspace-a', 101))
|
|
229
|
+
|
|
230
|
+
await api.resources.projects.put({
|
|
231
|
+
inputRecord: {
|
|
232
|
+
data: {
|
|
233
|
+
type: 'projects',
|
|
234
|
+
id: project.data.id,
|
|
235
|
+
attributes: {
|
|
236
|
+
name: 'Updated Project',
|
|
237
|
+
description: 'Updated Description',
|
|
238
|
+
status: 'active'
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
simplified: false
|
|
243
|
+
}, scopedContext('workspace-a', 101))
|
|
244
|
+
|
|
245
|
+
const afterPut = await api.resources.projects.get({
|
|
246
|
+
id: project.data.id,
|
|
247
|
+
simplified: false
|
|
248
|
+
}, scopedContext('workspace-a', 101))
|
|
249
|
+
|
|
250
|
+
validateJsonApiStructure(afterPut)
|
|
251
|
+
assertResourceAttributes(afterPut.data, {
|
|
252
|
+
name: 'Updated Project',
|
|
253
|
+
description: 'Updated Description',
|
|
254
|
+
workspace_id: 'workspace-a',
|
|
255
|
+
user_id: 101
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
await assert.rejects(
|
|
259
|
+
async () => {
|
|
260
|
+
await api.resources.projects.patch({
|
|
261
|
+
id: project.data.id,
|
|
262
|
+
inputRecord: {
|
|
263
|
+
data: {
|
|
264
|
+
type: 'projects',
|
|
265
|
+
id: project.data.id,
|
|
266
|
+
attributes: {
|
|
267
|
+
workspace_id: 'workspace-b'
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
simplified: false
|
|
272
|
+
}, scopedContext('workspace-a', 101))
|
|
273
|
+
},
|
|
274
|
+
(error) => error.code === 'REST_API_VALIDATION',
|
|
275
|
+
'PATCH should reject inconsistent scope-field updates'
|
|
276
|
+
)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('uses scoped lookups when validating relationships', async () => {
|
|
280
|
+
const projectA = await api.resources.projects.post({
|
|
281
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
282
|
+
name: 'Project A'
|
|
283
|
+
}),
|
|
284
|
+
simplified: false
|
|
285
|
+
}, scopedContext('workspace-a', 101))
|
|
286
|
+
|
|
287
|
+
const projectB = await api.resources.projects.post({
|
|
288
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
289
|
+
name: 'Project B'
|
|
290
|
+
}),
|
|
291
|
+
simplified: false
|
|
292
|
+
}, scopedContext('workspace-b', 101))
|
|
293
|
+
|
|
294
|
+
const task = await api.resources.tasks.post({
|
|
295
|
+
inputRecord: createJsonApiDocument('tasks',
|
|
296
|
+
{
|
|
297
|
+
title: 'Task A'
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
project: createRelationship(resourceIdentifier('projects', projectA.data.id))
|
|
301
|
+
}
|
|
302
|
+
),
|
|
303
|
+
simplified: false
|
|
304
|
+
}, scopedContext('workspace-a', 101))
|
|
305
|
+
|
|
306
|
+
validateJsonApiStructure(task)
|
|
307
|
+
assertResourceAttributes(task.data, {
|
|
308
|
+
title: 'Task A',
|
|
309
|
+
workspace_id: 'workspace-a',
|
|
310
|
+
user_id: 101
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
await assert.rejects(
|
|
314
|
+
async () => {
|
|
315
|
+
await api.resources.tasks.post({
|
|
316
|
+
inputRecord: createJsonApiDocument('tasks',
|
|
317
|
+
{
|
|
318
|
+
title: 'Cross-scope Task'
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
project: createRelationship(resourceIdentifier('projects', projectB.data.id))
|
|
322
|
+
}
|
|
323
|
+
),
|
|
324
|
+
simplified: false
|
|
325
|
+
}, scopedContext('workspace-a', 101))
|
|
326
|
+
},
|
|
327
|
+
(error) => error.code === 'REST_API_RESOURCE' || error.code === 'REST_API_VALIDATION',
|
|
328
|
+
'Relationship validation should respect scoped datasets'
|
|
329
|
+
)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('scopes included resources through the same autofilter path as top-level queries', async () => {
|
|
333
|
+
const project = await api.resources.projects.post({
|
|
334
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
335
|
+
name: 'Scoped include project'
|
|
336
|
+
}),
|
|
337
|
+
simplified: false
|
|
338
|
+
}, scopedContext('workspace-a', 101))
|
|
339
|
+
|
|
340
|
+
const visibleTask = await api.resources.tasks.post({
|
|
341
|
+
inputRecord: createJsonApiDocument('tasks',
|
|
342
|
+
{
|
|
343
|
+
title: 'Visible task'
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
project: createRelationship(resourceIdentifier('projects', project.data.id))
|
|
347
|
+
}
|
|
348
|
+
),
|
|
349
|
+
simplified: false
|
|
350
|
+
}, scopedContext('workspace-a', 101))
|
|
351
|
+
|
|
352
|
+
const hiddenTask = await api.resources.tasks.post({
|
|
353
|
+
inputRecord: createJsonApiDocument('tasks',
|
|
354
|
+
{
|
|
355
|
+
title: 'Out-of-scope task'
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
project: createRelationship(resourceIdentifier('projects', project.data.id))
|
|
359
|
+
}
|
|
360
|
+
),
|
|
361
|
+
simplified: false
|
|
362
|
+
}, scopedContext('workspace-a', 101))
|
|
363
|
+
|
|
364
|
+
await moveTaskOutOfScope({
|
|
365
|
+
taskId: hiddenTask.data.id,
|
|
366
|
+
workspaceId: 'workspace-b',
|
|
367
|
+
userId: 202
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const result = await api.resources.projects.get({
|
|
371
|
+
id: project.data.id,
|
|
372
|
+
queryParams: {
|
|
373
|
+
include: ['tasks']
|
|
374
|
+
},
|
|
375
|
+
simplified: false
|
|
376
|
+
}, scopedContext('workspace-a', 101))
|
|
377
|
+
|
|
378
|
+
validateJsonApiStructure(result)
|
|
379
|
+
assert.deepEqual(result.data.relationships.tasks.data, [
|
|
380
|
+
resourceIdentifier('tasks', visibleTask.data.id)
|
|
381
|
+
])
|
|
382
|
+
assert.equal(result.included?.length, 1)
|
|
383
|
+
assert.equal(result.included?.[0]?.id, visibleTask.data.id)
|
|
384
|
+
assert.equal(result.included?.[0]?.attributes.title, 'Visible task')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('treats null belongsTo autofilter values as valid null linkage on writes', async () => {
|
|
388
|
+
const implicitNull = await api.resources.optional_tasks.post({
|
|
389
|
+
inputRecord: createJsonApiDocument('optional_tasks', {
|
|
390
|
+
title: 'Implicit null project'
|
|
391
|
+
}),
|
|
392
|
+
simplified: false
|
|
393
|
+
}, scopedContext('workspace-a', 101))
|
|
394
|
+
|
|
395
|
+
const explicitNull = await api.resources.optional_tasks.post({
|
|
396
|
+
inputRecord: createJsonApiDocument(
|
|
397
|
+
'optional_tasks',
|
|
398
|
+
{
|
|
399
|
+
title: 'Explicit null project'
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
project: { data: null }
|
|
403
|
+
}
|
|
404
|
+
),
|
|
405
|
+
simplified: false
|
|
406
|
+
}, scopedContext('workspace-a', 101))
|
|
407
|
+
|
|
408
|
+
const implicitFetched = await api.resources.optional_tasks.get({
|
|
409
|
+
id: implicitNull.data.id,
|
|
410
|
+
simplified: false
|
|
411
|
+
}, scopedContext('workspace-a', 101))
|
|
412
|
+
|
|
413
|
+
const explicitFetched = await api.resources.optional_tasks.get({
|
|
414
|
+
id: explicitNull.data.id,
|
|
415
|
+
simplified: false
|
|
416
|
+
}, scopedContext('workspace-a', 101))
|
|
417
|
+
|
|
418
|
+
validateJsonApiStructure(implicitFetched)
|
|
419
|
+
validateJsonApiStructure(explicitFetched)
|
|
420
|
+
assert.equal(implicitFetched.data.relationships.project.data, null)
|
|
421
|
+
assert.equal(explicitFetched.data.relationships.project.data, null)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('fails cleanly when a required autofilter value is missing', async () => {
|
|
425
|
+
await assert.rejects(
|
|
426
|
+
async () => {
|
|
427
|
+
await api.resources.projects.query({
|
|
428
|
+
simplified: false
|
|
429
|
+
})
|
|
430
|
+
},
|
|
431
|
+
(error) => error.code === 'REST_API_AUTOFILTER_CONTEXT',
|
|
432
|
+
'Scoped resources should require configured scope values'
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
await assert.rejects(
|
|
436
|
+
async () => {
|
|
437
|
+
await api.resources.projects.post({
|
|
438
|
+
inputRecord: createJsonApiDocument('projects', {
|
|
439
|
+
name: 'No Scope'
|
|
440
|
+
}),
|
|
441
|
+
simplified: false
|
|
442
|
+
})
|
|
443
|
+
},
|
|
444
|
+
(error) => error.code === 'REST_API_AUTOFILTER_CONTEXT',
|
|
445
|
+
'Scoped writes should require configured scope values'
|
|
446
|
+
)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('exposes compiled preset configuration without auth semantics', () => {
|
|
450
|
+
assert.deepEqual(api.autofilter.getConfig(), {
|
|
451
|
+
presets: ['public', 'workspace', 'user', 'workspace_user'],
|
|
452
|
+
resolvers: ['workspace', 'user', 'currentProject']
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
assert.deepEqual(api.autofilter.getScopeConfig('projects'), {
|
|
456
|
+
preset: 'workspace_user',
|
|
457
|
+
filters: [
|
|
458
|
+
{ field: 'workspace_id', resolver: 'workspace', required: true },
|
|
459
|
+
{ field: 'user_id', resolver: 'user', required: true }
|
|
460
|
+
]
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { cleanTables } from './helpers/test-utils.js'
|
|
8
|
+
|
|
9
|
+
const knex = knexLib({
|
|
10
|
+
client: 'better-sqlite3',
|
|
11
|
+
connection: {
|
|
12
|
+
filename: ':memory:'
|
|
13
|
+
},
|
|
14
|
+
useNullAsDefault: true
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
let api
|
|
18
|
+
|
|
19
|
+
describe('Simplified POST with explicit resource ids', () => {
|
|
20
|
+
before(async () => {
|
|
21
|
+
api = new Api({
|
|
22
|
+
name: 'custom-id-simplified-post-test'
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
await api.use(RestApiPlugin, {
|
|
26
|
+
simplifiedApi: true,
|
|
27
|
+
simplifiedTransport: false,
|
|
28
|
+
returnRecordApi: {
|
|
29
|
+
post: 'full',
|
|
30
|
+
put: 'full',
|
|
31
|
+
patch: 'full'
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
await api.use(RestApiKnexPlugin, { knex })
|
|
35
|
+
|
|
36
|
+
await api.addResource('user_settings', {
|
|
37
|
+
tableName: 'test_user_settings',
|
|
38
|
+
idProperty: 'user_id',
|
|
39
|
+
searchSchema: {
|
|
40
|
+
id: { type: 'id', actualField: 'id' }
|
|
41
|
+
},
|
|
42
|
+
schema: {
|
|
43
|
+
theme: { type: 'string', required: true, max: 32 }
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
await api.resources.user_settings.createKnexTable()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
after(async () => {
|
|
50
|
+
await knex.destroy()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
await cleanTables(knex, ['test_user_settings'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('persists the provided resource id when posting simplified records to a custom-id resource', async () => {
|
|
58
|
+
const created = await api.resources.user_settings.post({
|
|
59
|
+
id: '7',
|
|
60
|
+
theme: 'dark'
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
assert.equal(created.id, '7')
|
|
64
|
+
assert.equal(created.theme, 'dark')
|
|
65
|
+
|
|
66
|
+
const dbRow = await knex('test_user_settings').where('user_id', 7).first()
|
|
67
|
+
assert.ok(dbRow)
|
|
68
|
+
assert.equal(String(dbRow.user_id), '7')
|
|
69
|
+
assert.equal(dbRow.theme, 'dark')
|
|
70
|
+
|
|
71
|
+
const fetched = await api.resources.user_settings.get({ id: '7' })
|
|
72
|
+
assert.equal(fetched.id, '7')
|
|
73
|
+
assert.equal(fetched.theme, 'dark')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -161,6 +161,14 @@ describe('Custom idProperty Relationship Operations', () => {
|
|
|
161
161
|
assert.equal(getResult.included.length, 1)
|
|
162
162
|
assert.equal(getResult.included[0].type, 'countries')
|
|
163
163
|
assert.equal(getResult.included[0].attributes.name, 'United States')
|
|
164
|
+
assert.equal(
|
|
165
|
+
getResult.data.relationships.country.links.self,
|
|
166
|
+
`/publishers/${publisherResult.data.id}/relationships/country`
|
|
167
|
+
)
|
|
168
|
+
assert.equal(
|
|
169
|
+
getResult.data.relationships.country.links.related,
|
|
170
|
+
`/publishers/${publisherResult.data.id}/country`
|
|
171
|
+
)
|
|
164
172
|
})
|
|
165
173
|
|
|
166
174
|
it('should update belongsTo relationship via PATCH', async () => {
|
|
@@ -895,5 +903,30 @@ describe('Custom idProperty Relationship Operations', () => {
|
|
|
895
903
|
assertResourceRelationship(putResult.data, 'country',
|
|
896
904
|
resourceIdentifier('countries', country.data.id))
|
|
897
905
|
})
|
|
906
|
+
|
|
907
|
+
it('should reject PUT when an existing persisted attribute is omitted', async () => {
|
|
908
|
+
const country = await api.resources.countries.post({
|
|
909
|
+
inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' })
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
await assert.rejects(
|
|
913
|
+
async () => api.resources.countries.put({
|
|
914
|
+
inputRecord: {
|
|
915
|
+
data: {
|
|
916
|
+
type: 'countries',
|
|
917
|
+
id: country.data.id,
|
|
918
|
+
attributes: {
|
|
919
|
+
name: 'Renamed Country'
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}),
|
|
924
|
+
(error) => {
|
|
925
|
+
assert.equal(error.message, 'PUT request omits persisted fields that already have stored values')
|
|
926
|
+
assert(error.details.fields.includes('data.attributes.code'))
|
|
927
|
+
return true
|
|
928
|
+
}
|
|
929
|
+
)
|
|
930
|
+
})
|
|
898
931
|
})
|
|
899
932
|
})
|