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.
- package/README.md +2 -1
- package/docs/API.md +12 -9
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +5 -4
- package/plugins/core/connectors/express-plugin.js +6 -1
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/schema-utils.js +1 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
- package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
- package/plugins/core/lib/storage/storage-adapter.js +74 -13
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
- package/plugins/core/rest-api-knex-plugin.js +70 -60
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
- package/plugins/core/rest-api-plugin-methods/common.js +85 -63
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
- package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
- package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
- package/plugins/core/rest-api-plugin-methods/post.js +4 -7
- package/plugins/core/rest-api-plugin-methods/put.js +4 -7
- package/plugins/core/rest-api-plugin.js +6 -36
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/storage/local-storage.js +44 -19
- package/plugins/storage/s3-storage.js +21 -55
- package/tests/README.md +1 -1
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/fastify-plugin.test.js +28 -0
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +362 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/local-storage.test.js +96 -0
- package/tests/pagination-cursor-multifield.test.js +121 -0
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +433 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +1 -0
- package/tests/test-development-guide.md +5 -5
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
cleanTables,
|
|
6
|
+
countRecords,
|
|
7
|
+
createJsonApiDocument,
|
|
8
|
+
createRelationship,
|
|
9
|
+
resourceIdentifier
|
|
10
|
+
} from './helpers/test-utils.js'
|
|
11
|
+
import { createBasicApi } from './fixtures/api-configs.js'
|
|
12
|
+
|
|
13
|
+
const knex = knexLib({
|
|
14
|
+
client: 'better-sqlite3',
|
|
15
|
+
connection: {
|
|
16
|
+
filename: ':memory:'
|
|
17
|
+
},
|
|
18
|
+
useNullAsDefault: true
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
let api
|
|
22
|
+
let afterCommitFailureMethod = null
|
|
23
|
+
const rollbackEvents = []
|
|
24
|
+
|
|
25
|
+
describe('Post-commit hook failure semantics', () => {
|
|
26
|
+
before(async () => {
|
|
27
|
+
api = await createBasicApi(knex, {
|
|
28
|
+
apiName: 'post-commit-hook-test',
|
|
29
|
+
tablePrefix: 'post_commit'
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
await api.customize({
|
|
33
|
+
hooks: {
|
|
34
|
+
afterCommit: {
|
|
35
|
+
functionName: 'post-commit-failure-test',
|
|
36
|
+
handler: async ({ context }) => {
|
|
37
|
+
if (context.method === afterCommitFailureMethod) {
|
|
38
|
+
throw new Error(`afterCommit failed for ${context.method}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
afterRollback: {
|
|
43
|
+
functionName: 'post-commit-rollback-tracker',
|
|
44
|
+
handler: async ({ context }) => {
|
|
45
|
+
rollbackEvents.push(context.method)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
after(async () => {
|
|
53
|
+
await knex.destroy()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
afterCommitFailureMethod = null
|
|
58
|
+
rollbackEvents.length = 0
|
|
59
|
+
|
|
60
|
+
await cleanTables(knex, [
|
|
61
|
+
'post_commit_countries',
|
|
62
|
+
'post_commit_publishers',
|
|
63
|
+
'post_commit_authors',
|
|
64
|
+
'post_commit_books',
|
|
65
|
+
'post_commit_book_authors'
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('surfaces afterCommit errors without rolling back committed resource writes', async () => {
|
|
70
|
+
afterCommitFailureMethod = 'post'
|
|
71
|
+
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
api.resources.countries.post({
|
|
74
|
+
inputRecord: createJsonApiDocument('countries', {
|
|
75
|
+
name: 'Committed Country',
|
|
76
|
+
code: 'CC'
|
|
77
|
+
}),
|
|
78
|
+
simplified: false
|
|
79
|
+
}),
|
|
80
|
+
/afterCommit failed for post/
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert.equal(await countRecords(knex, 'post_commit_countries'), 1)
|
|
84
|
+
assert.deepEqual(rollbackEvents, [])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('surfaces afterCommit errors without rolling back committed relationship writes', async () => {
|
|
88
|
+
const country = await api.resources.countries.post({
|
|
89
|
+
inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' }),
|
|
90
|
+
simplified: false
|
|
91
|
+
})
|
|
92
|
+
const book = await api.resources.books.post({
|
|
93
|
+
inputRecord: createJsonApiDocument(
|
|
94
|
+
'books',
|
|
95
|
+
{ title: 'Committed Book' },
|
|
96
|
+
{ country: createRelationship(resourceIdentifier('countries', country.data.id)) }
|
|
97
|
+
),
|
|
98
|
+
simplified: false
|
|
99
|
+
})
|
|
100
|
+
const author = await api.resources.authors.post({
|
|
101
|
+
inputRecord: createJsonApiDocument('authors', { name: 'Committed Author' }),
|
|
102
|
+
simplified: false
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
rollbackEvents.length = 0
|
|
106
|
+
afterCommitFailureMethod = 'postRelationship'
|
|
107
|
+
|
|
108
|
+
await assert.rejects(
|
|
109
|
+
api.resources.books.postRelationship({
|
|
110
|
+
id: book.data.id,
|
|
111
|
+
relationshipName: 'authors',
|
|
112
|
+
relationshipData: [resourceIdentifier('authors', author.data.id)]
|
|
113
|
+
}),
|
|
114
|
+
/afterCommit failed for postRelationship/
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert.equal(await countRecords(knex, 'post_commit_book_authors'), 1)
|
|
118
|
+
assert.deepEqual(rollbackEvents, [])
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { RowPolicyPlugin, SocketIOPlugin } from '../index.js'
|
|
4
|
+
|
|
5
|
+
describe('Public exports', () => {
|
|
6
|
+
it('exports the documented Socket.IO plugin from the package entrypoint', () => {
|
|
7
|
+
assert.equal(SocketIOPlugin.name, 'socketio')
|
|
8
|
+
assert.equal(typeof SocketIOPlugin.install, 'function')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('exports the row-policy plugin from the package entrypoint', () => {
|
|
12
|
+
assert.equal(RowPolicyPlugin.name, 'row-policy')
|
|
13
|
+
assert.equal(typeof RowPolicyPlugin.install, 'function')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
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 {
|
|
7
|
+
RestApiAnyapiKnexPlugin,
|
|
8
|
+
RestApiKnexPlugin,
|
|
9
|
+
RestApiPlugin,
|
|
10
|
+
} from '../index.js'
|
|
11
|
+
import { ensureAnyApiSchema } from '../plugins/core/lib/anyapi/schema-utils.js'
|
|
12
|
+
import {
|
|
13
|
+
cleanTables,
|
|
14
|
+
validateJsonApiStructure,
|
|
15
|
+
} from './helpers/test-utils.js'
|
|
16
|
+
import { storageMode } from './helpers/storage-mode.js'
|
|
17
|
+
|
|
18
|
+
const knex = knexLib({
|
|
19
|
+
client: 'better-sqlite3',
|
|
20
|
+
connection: {
|
|
21
|
+
filename: ':memory:'
|
|
22
|
+
},
|
|
23
|
+
useNullAsDefault: true
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const tenantId = 'put_create_parity_tenant'
|
|
27
|
+
const profilesTable = 'put_create_profiles'
|
|
28
|
+
const accountsTable = 'put_create_accounts'
|
|
29
|
+
|
|
30
|
+
let api
|
|
31
|
+
|
|
32
|
+
const registerTable = (tableName, resourceName) => {
|
|
33
|
+
if (storageMode.isAnyApi()) {
|
|
34
|
+
storageMode.registerTable(tableName, resourceName, tenantId)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const findStoredRow = async (tableName, resourceName, idColumn, id) => {
|
|
39
|
+
if (storageMode.isAnyApi()) {
|
|
40
|
+
return knex('any_records')
|
|
41
|
+
.where({
|
|
42
|
+
tenant_id: tenantId,
|
|
43
|
+
resource: resourceName,
|
|
44
|
+
logical_id: String(id),
|
|
45
|
+
})
|
|
46
|
+
.first()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return knex(tableName)
|
|
50
|
+
.where(idColumn, id)
|
|
51
|
+
.first()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const countStoredRows = async (tableName, resourceName, idColumn, id) => {
|
|
55
|
+
if (storageMode.isAnyApi()) {
|
|
56
|
+
const row = await knex('any_records')
|
|
57
|
+
.where({
|
|
58
|
+
tenant_id: tenantId,
|
|
59
|
+
resource: resourceName,
|
|
60
|
+
logical_id: String(id),
|
|
61
|
+
})
|
|
62
|
+
.count('* as count')
|
|
63
|
+
.first()
|
|
64
|
+
return Number(row?.count || 0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const row = await knex(tableName)
|
|
68
|
+
.where(idColumn, id)
|
|
69
|
+
.count('* as count')
|
|
70
|
+
.first()
|
|
71
|
+
return Number(row?.count || 0)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe(`PUT create parity (${storageMode.mode})`, () => {
|
|
75
|
+
before(async () => {
|
|
76
|
+
if (storageMode.isAnyApi()) {
|
|
77
|
+
storageMode.clearRegistry()
|
|
78
|
+
storageMode.setCurrentTenant(tenantId)
|
|
79
|
+
await ensureAnyApiSchema(knex)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
api = new Api({
|
|
83
|
+
name: 'put-create-parity-test',
|
|
84
|
+
log: { level: 'warn' }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
await api.use(RestApiPlugin, {
|
|
88
|
+
simplifiedApi: false,
|
|
89
|
+
simplifiedTransport: false,
|
|
90
|
+
returnRecordApi: {
|
|
91
|
+
post: 'full',
|
|
92
|
+
put: 'full',
|
|
93
|
+
patch: 'full'
|
|
94
|
+
},
|
|
95
|
+
returnRecordTransport: {
|
|
96
|
+
post: 'full',
|
|
97
|
+
put: 'full',
|
|
98
|
+
patch: 'full'
|
|
99
|
+
},
|
|
100
|
+
sortableFields: ['id', 'account_id', 'name', 'code']
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (storageMode.isAnyApi()) {
|
|
104
|
+
await api.use(RestApiAnyapiKnexPlugin, { knex, tenantId })
|
|
105
|
+
} else {
|
|
106
|
+
await api.use(RestApiKnexPlugin, { knex })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await api.addResource('profiles', {
|
|
110
|
+
tableName: profilesTable,
|
|
111
|
+
schema: {
|
|
112
|
+
id: { type: 'id' },
|
|
113
|
+
name: { type: 'string', required: true },
|
|
114
|
+
code: { type: 'string', required: true },
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
await api.resources.profiles.createKnexTable()
|
|
118
|
+
registerTable(profilesTable, 'profiles')
|
|
119
|
+
|
|
120
|
+
await api.addResource('accounts', {
|
|
121
|
+
tableName: accountsTable,
|
|
122
|
+
idProperty: 'account_id',
|
|
123
|
+
schema: {
|
|
124
|
+
name: { type: 'string', required: true },
|
|
125
|
+
code: { type: 'string', required: true },
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
await api.resources.accounts.createKnexTable()
|
|
129
|
+
registerTable(accountsTable, 'accounts')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
after(async () => {
|
|
133
|
+
await api?.release()
|
|
134
|
+
await knex.destroy()
|
|
135
|
+
if (storageMode.isAnyApi()) {
|
|
136
|
+
storageMode.clearRegistry()
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
beforeEach(async () => {
|
|
141
|
+
await cleanTables(knex, [profilesTable, accountsTable])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('creates a missing resource through PUT with the default id property', async () => {
|
|
145
|
+
const created = await api.resources.profiles.put({
|
|
146
|
+
id: '101',
|
|
147
|
+
inputRecord: {
|
|
148
|
+
data: {
|
|
149
|
+
type: 'profiles',
|
|
150
|
+
id: '101',
|
|
151
|
+
attributes: {
|
|
152
|
+
name: 'Created Profile',
|
|
153
|
+
code: 'CP'
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
simplified: false
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
validateJsonApiStructure(created)
|
|
161
|
+
assert.equal(created.data.id, '101')
|
|
162
|
+
assert.equal(created.data.attributes.name, 'Created Profile')
|
|
163
|
+
|
|
164
|
+
const stored = await findStoredRow(profilesTable, 'profiles', 'id', 101)
|
|
165
|
+
assert(stored, 'PUT create should persist a row')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('creates and then replaces a missing resource through PUT with a custom id property', async () => {
|
|
169
|
+
const created = await api.resources.accounts.put({
|
|
170
|
+
id: '501',
|
|
171
|
+
inputRecord: {
|
|
172
|
+
data: {
|
|
173
|
+
type: 'accounts',
|
|
174
|
+
id: '501',
|
|
175
|
+
attributes: {
|
|
176
|
+
name: 'Created Account',
|
|
177
|
+
code: 'CA'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
simplified: false
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
validateJsonApiStructure(created)
|
|
185
|
+
assert.equal(created.data.id, '501')
|
|
186
|
+
assert.equal(created.data.attributes.name, 'Created Account')
|
|
187
|
+
assert.equal(created.data.attributes.account_id, undefined)
|
|
188
|
+
|
|
189
|
+
const replaced = await api.resources.accounts.put({
|
|
190
|
+
id: '501',
|
|
191
|
+
inputRecord: {
|
|
192
|
+
data: {
|
|
193
|
+
type: 'accounts',
|
|
194
|
+
id: '501',
|
|
195
|
+
attributes: {
|
|
196
|
+
name: 'Replaced Account',
|
|
197
|
+
code: 'RA'
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
simplified: false
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
assert.equal(replaced.data.id, '501')
|
|
205
|
+
assert.equal(replaced.data.attributes.name, 'Replaced Account')
|
|
206
|
+
assert.equal(await countStoredRows(accountsTable, 'accounts', 'account_id', 501), 1)
|
|
207
|
+
|
|
208
|
+
const fetched = await api.resources.accounts.get({
|
|
209
|
+
id: '501',
|
|
210
|
+
simplified: false
|
|
211
|
+
})
|
|
212
|
+
assert.equal(fetched.data.attributes.name, 'Replaced Account')
|
|
213
|
+
assert.equal(fetched.data.attributes.code, 'RA')
|
|
214
|
+
assert.equal(fetched.data.attributes.account_id, undefined)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, before, after, beforeEach } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import express from 'express'
|
|
4
|
+
import request from 'supertest'
|
|
3
5
|
import knexLib from 'knex'
|
|
4
6
|
import {
|
|
5
7
|
validateJsonApiStructure,
|
|
@@ -23,16 +25,50 @@ const knex = knexLib({
|
|
|
23
25
|
|
|
24
26
|
// API instance that persists across tests
|
|
25
27
|
let api
|
|
28
|
+
let app
|
|
29
|
+
const relationshipCommitEvents = []
|
|
30
|
+
const relationshipRollbackEvents = []
|
|
26
31
|
|
|
27
32
|
describe('Relationship Endpoints Plugin', () => {
|
|
28
33
|
before(async () => {
|
|
29
34
|
// Initialize API with the relationships plugin
|
|
35
|
+
app = express()
|
|
30
36
|
api = await createBasicApi(knex, {
|
|
31
37
|
express: {
|
|
32
38
|
mountPath: '' // No mount path for this test
|
|
33
39
|
},
|
|
34
40
|
includeExpress: true
|
|
35
41
|
})
|
|
42
|
+
api.http.express.mount(app)
|
|
43
|
+
|
|
44
|
+
await api.customize({
|
|
45
|
+
hooks: {
|
|
46
|
+
afterCommit: {
|
|
47
|
+
functionName: 'relationship-after-commit-test-tracker',
|
|
48
|
+
handler: async ({ context }) => {
|
|
49
|
+
if (context.method?.endsWith('Relationship')) {
|
|
50
|
+
relationshipCommitEvents.push({
|
|
51
|
+
method: context.method,
|
|
52
|
+
shouldCommit: context.shouldCommit,
|
|
53
|
+
hasTransaction: !!context.transaction
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
afterRollback: {
|
|
59
|
+
functionName: 'relationship-after-rollback-test-tracker',
|
|
60
|
+
handler: async ({ context }) => {
|
|
61
|
+
if (context.method?.endsWith('Relationship')) {
|
|
62
|
+
relationshipRollbackEvents.push({
|
|
63
|
+
method: context.method,
|
|
64
|
+
shouldCommit: context.shouldCommit,
|
|
65
|
+
hasTransaction: !!context.transaction
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})
|
|
36
72
|
})
|
|
37
73
|
|
|
38
74
|
after(async () => {
|
|
@@ -358,4 +394,227 @@ describe('Relationship Endpoints Plugin', () => {
|
|
|
358
394
|
// Note: belongsTo relationships (like 'publisher') are not accessible via relationship endpoints
|
|
359
395
|
// They are foreign key fields, not true JSON:API relationships
|
|
360
396
|
})
|
|
397
|
+
|
|
398
|
+
describe('Relationship Cardinality Validation', () => {
|
|
399
|
+
let countryId
|
|
400
|
+
let bookId
|
|
401
|
+
let authorId
|
|
402
|
+
|
|
403
|
+
const assertRelationshipIdValidationError = (error) => {
|
|
404
|
+
assert.equal(error.code, 'REST_API_VALIDATION')
|
|
405
|
+
assert.match(error.message, /id/i)
|
|
406
|
+
return true
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
beforeEach(async () => {
|
|
410
|
+
await cleanTables(knex, [
|
|
411
|
+
'basic_countries', 'basic_publishers', 'basic_authors', 'basic_books', 'basic_book_authors'
|
|
412
|
+
])
|
|
413
|
+
|
|
414
|
+
const country = await api.resources.countries.post({
|
|
415
|
+
inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' }),
|
|
416
|
+
simplified: false
|
|
417
|
+
})
|
|
418
|
+
countryId = country.data.id
|
|
419
|
+
|
|
420
|
+
const book = await api.resources.books.post({
|
|
421
|
+
inputRecord: createJsonApiDocument(
|
|
422
|
+
'books',
|
|
423
|
+
{ title: 'Cardinality Book' },
|
|
424
|
+
{ country: createRelationship(resourceIdentifier('countries', countryId)) }
|
|
425
|
+
),
|
|
426
|
+
simplified: false
|
|
427
|
+
})
|
|
428
|
+
bookId = book.data.id
|
|
429
|
+
|
|
430
|
+
const author = await api.resources.authors.post({
|
|
431
|
+
inputRecord: createJsonApiDocument('authors', { name: 'Author One' }),
|
|
432
|
+
simplified: false
|
|
433
|
+
})
|
|
434
|
+
authorId = author.data.id
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('rejects arrays for to-one relationship PATCH calls', async () => {
|
|
438
|
+
await assert.rejects(
|
|
439
|
+
api.resources.books.patchRelationship({
|
|
440
|
+
id: bookId,
|
|
441
|
+
relationshipName: 'country',
|
|
442
|
+
relationshipData: [resourceIdentifier('countries', countryId)]
|
|
443
|
+
}),
|
|
444
|
+
(error) => {
|
|
445
|
+
assert.equal(error.code, 'REST_API_VALIDATION')
|
|
446
|
+
assert.match(error.message, /single resource identifier or null/)
|
|
447
|
+
assert(error.details.fields.includes('data.relationships.country.data'))
|
|
448
|
+
return true
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('rejects scalars for to-many relationship PATCH calls', async () => {
|
|
454
|
+
await assert.rejects(
|
|
455
|
+
api.resources.books.patchRelationship({
|
|
456
|
+
id: bookId,
|
|
457
|
+
relationshipName: 'authors',
|
|
458
|
+
relationshipData: resourceIdentifier('authors', authorId)
|
|
459
|
+
}),
|
|
460
|
+
(error) => {
|
|
461
|
+
assert.equal(error.code, 'REST_API_VALIDATION')
|
|
462
|
+
assert.match(error.message, /array of resource identifiers/)
|
|
463
|
+
assert(error.details.fields.includes('data.relationships.authors.data'))
|
|
464
|
+
return true
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('rejects invalid relationship cardinality through Express transport', async () => {
|
|
470
|
+
const toOneResponse = await request(app)
|
|
471
|
+
.patch(`/books/${bookId}/relationships/country`)
|
|
472
|
+
.set('Content-Type', 'application/vnd.api+json')
|
|
473
|
+
.set('Accept', 'application/vnd.api+json')
|
|
474
|
+
.send({
|
|
475
|
+
data: [resourceIdentifier('countries', countryId)]
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
assert.equal(toOneResponse.status, 422)
|
|
479
|
+
assert.match(toOneResponse.body.errors?.[0]?.detail || '', /single resource identifier or null/)
|
|
480
|
+
|
|
481
|
+
const toManyResponse = await request(app)
|
|
482
|
+
.patch(`/books/${bookId}/relationships/authors`)
|
|
483
|
+
.set('Content-Type', 'application/vnd.api+json')
|
|
484
|
+
.set('Accept', 'application/vnd.api+json')
|
|
485
|
+
.send({
|
|
486
|
+
data: resourceIdentifier('authors', authorId)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
assert.equal(toManyResponse.status, 422)
|
|
490
|
+
assert.match(toManyResponse.body.errors?.[0]?.detail || '', /array of resource identifiers/)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('rejects malformed relationship identifiers through direct relationship methods', async () => {
|
|
494
|
+
await assert.rejects(
|
|
495
|
+
api.resources.books.postRelationship({
|
|
496
|
+
id: bookId,
|
|
497
|
+
relationshipName: 'authors',
|
|
498
|
+
relationshipData: [{ type: 'authors' }]
|
|
499
|
+
}),
|
|
500
|
+
assertRelationshipIdValidationError
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
await assert.rejects(
|
|
504
|
+
api.resources.books.patchRelationship({
|
|
505
|
+
id: bookId,
|
|
506
|
+
relationshipName: 'country',
|
|
507
|
+
relationshipData: { type: 'countries' }
|
|
508
|
+
}),
|
|
509
|
+
assertRelationshipIdValidationError
|
|
510
|
+
)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('rejects malformed relationship identifiers through Express transport', async () => {
|
|
514
|
+
const response = await request(app)
|
|
515
|
+
.delete(`/books/${bookId}/relationships/authors`)
|
|
516
|
+
.set('Content-Type', 'application/vnd.api+json')
|
|
517
|
+
.set('Accept', 'application/vnd.api+json')
|
|
518
|
+
.send({
|
|
519
|
+
data: [{ type: 'authors' }]
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
assert.equal(response.status, 422)
|
|
523
|
+
assert.match(response.body.errors?.[0]?.detail || '', /id/i)
|
|
524
|
+
})
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
describe('Relationship Transaction Hooks', () => {
|
|
528
|
+
let countryId
|
|
529
|
+
let secondCountryId
|
|
530
|
+
let bookId
|
|
531
|
+
let authorId
|
|
532
|
+
|
|
533
|
+
beforeEach(async () => {
|
|
534
|
+
relationshipCommitEvents.length = 0
|
|
535
|
+
relationshipRollbackEvents.length = 0
|
|
536
|
+
|
|
537
|
+
await cleanTables(knex, [
|
|
538
|
+
'basic_countries', 'basic_publishers', 'basic_authors', 'basic_books', 'basic_book_authors'
|
|
539
|
+
])
|
|
540
|
+
|
|
541
|
+
const country = await api.resources.countries.post({
|
|
542
|
+
inputRecord: createJsonApiDocument('countries', { name: 'USA', code: 'US' }),
|
|
543
|
+
simplified: false
|
|
544
|
+
})
|
|
545
|
+
countryId = country.data.id
|
|
546
|
+
|
|
547
|
+
const secondCountry = await api.resources.countries.post({
|
|
548
|
+
inputRecord: createJsonApiDocument('countries', { name: 'Canada', code: 'CA' }),
|
|
549
|
+
simplified: false
|
|
550
|
+
})
|
|
551
|
+
secondCountryId = secondCountry.data.id
|
|
552
|
+
|
|
553
|
+
const book = await api.resources.books.post({
|
|
554
|
+
inputRecord: createJsonApiDocument(
|
|
555
|
+
'books',
|
|
556
|
+
{ title: 'Hook Book' },
|
|
557
|
+
{ country: createRelationship(resourceIdentifier('countries', countryId)) }
|
|
558
|
+
),
|
|
559
|
+
simplified: false
|
|
560
|
+
})
|
|
561
|
+
bookId = book.data.id
|
|
562
|
+
|
|
563
|
+
const author = await api.resources.authors.post({
|
|
564
|
+
inputRecord: createJsonApiDocument('authors', { name: 'Hook Author' }),
|
|
565
|
+
simplified: false
|
|
566
|
+
})
|
|
567
|
+
authorId = author.data.id
|
|
568
|
+
|
|
569
|
+
relationshipCommitEvents.length = 0
|
|
570
|
+
relationshipRollbackEvents.length = 0
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it('runs afterCommit for relationship methods that own their transaction', async () => {
|
|
574
|
+
await api.resources.books.postRelationship({
|
|
575
|
+
id: bookId,
|
|
576
|
+
relationshipName: 'authors',
|
|
577
|
+
relationshipData: [resourceIdentifier('authors', authorId)]
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
await api.resources.books.patchRelationship({
|
|
581
|
+
id: bookId,
|
|
582
|
+
relationshipName: 'country',
|
|
583
|
+
relationshipData: resourceIdentifier('countries', secondCountryId)
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
await api.resources.books.deleteRelationship({
|
|
587
|
+
id: bookId,
|
|
588
|
+
relationshipName: 'authors',
|
|
589
|
+
relationshipData: [resourceIdentifier('authors', authorId)]
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
assert.deepEqual(
|
|
593
|
+
relationshipCommitEvents.map((event) => event.method),
|
|
594
|
+
['postRelationship', 'patchRelationship', 'deleteRelationship']
|
|
595
|
+
)
|
|
596
|
+
assert(relationshipCommitEvents.every((event) => event.shouldCommit))
|
|
597
|
+
assert(relationshipCommitEvents.every((event) => event.hasTransaction))
|
|
598
|
+
assert.equal(relationshipRollbackEvents.length, 0)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('preserves afterRollback for failed relationship methods that own their transaction', async () => {
|
|
602
|
+
await assert.rejects(
|
|
603
|
+
api.resources.books.postRelationship({
|
|
604
|
+
id: bookId,
|
|
605
|
+
relationshipName: 'missing',
|
|
606
|
+
relationshipData: []
|
|
607
|
+
}),
|
|
608
|
+
/Relationship 'missing' not found/
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
assert.deepEqual(
|
|
612
|
+
relationshipRollbackEvents.map((event) => event.method),
|
|
613
|
+
['postRelationship']
|
|
614
|
+
)
|
|
615
|
+
assert(relationshipRollbackEvents[0].shouldCommit)
|
|
616
|
+
assert(relationshipRollbackEvents[0].hasTransaction)
|
|
617
|
+
assert.equal(relationshipCommitEvents.length, 0)
|
|
618
|
+
})
|
|
619
|
+
})
|
|
361
620
|
})
|