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,235 @@
|
|
|
1
|
+
import { describe, it, before, beforeEach, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import express from 'express'
|
|
4
|
+
import request from 'supertest'
|
|
5
|
+
import knexLib from 'knex'
|
|
6
|
+
import { Api } from 'hooked-api'
|
|
7
|
+
import {
|
|
8
|
+
RestApiPlugin,
|
|
9
|
+
RestApiKnexPlugin,
|
|
10
|
+
ExpressPlugin,
|
|
11
|
+
FastifyPlugin
|
|
12
|
+
} from '../index.js'
|
|
13
|
+
import { getUnsupportedMediaTypeErrorBody } from '../plugins/core/connectors/lib/transport-http-helpers.js'
|
|
14
|
+
import { cleanTables, createJsonApiDocument } from './helpers/test-utils.js'
|
|
15
|
+
import {
|
|
16
|
+
FakeFastifyApp,
|
|
17
|
+
invokeFastifyRoute
|
|
18
|
+
} from './helpers/fake-fastify.js'
|
|
19
|
+
|
|
20
|
+
const knex = knexLib({
|
|
21
|
+
client: 'better-sqlite3',
|
|
22
|
+
connection: {
|
|
23
|
+
filename: ':memory:'
|
|
24
|
+
},
|
|
25
|
+
useNullAsDefault: true
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
async function createConnectorParityApi () {
|
|
29
|
+
const expressApp = express()
|
|
30
|
+
const fastifyApp = new FakeFastifyApp()
|
|
31
|
+
const api = new Api({
|
|
32
|
+
name: 'connector-parity-test-api',
|
|
33
|
+
log: { level: process.env.LOG_LEVEL || 'silent' }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await api.use(RestApiPlugin, {
|
|
37
|
+
simplifiedApi: false,
|
|
38
|
+
simplifiedTransport: false,
|
|
39
|
+
returnRecordApi: {
|
|
40
|
+
post: true,
|
|
41
|
+
put: false,
|
|
42
|
+
patch: false
|
|
43
|
+
},
|
|
44
|
+
returnRecordTransport: {
|
|
45
|
+
post: 'full',
|
|
46
|
+
put: 'no',
|
|
47
|
+
patch: 'minimal'
|
|
48
|
+
},
|
|
49
|
+
sortableFields: ['id', 'name', 'code']
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await api.use(RestApiKnexPlugin, { knex })
|
|
53
|
+
await api.use(ExpressPlugin, { mountPath: '/api' })
|
|
54
|
+
await api.use(FastifyPlugin, {
|
|
55
|
+
app: fastifyApp,
|
|
56
|
+
mountPath: '/api'
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await api.customize({
|
|
60
|
+
hooks: {
|
|
61
|
+
'transport:request': {
|
|
62
|
+
functionName: 'connector-parity-rejector',
|
|
63
|
+
handler: async ({ context }) => {
|
|
64
|
+
if (context.transport?.request?.headers?.['x-block-request'] === 'yes') {
|
|
65
|
+
context.reject(401, 'Blocked by connector parity hook', {
|
|
66
|
+
title: 'Unauthorized'
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
'transport:response': {
|
|
72
|
+
functionName: 'connector-parity-header',
|
|
73
|
+
handler: async ({ context }) => {
|
|
74
|
+
context.transport.response.headers['x-connector-test'] = 'enabled'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
await api.addResource('countries', {
|
|
81
|
+
schema: {
|
|
82
|
+
id: { type: 'id' },
|
|
83
|
+
name: { type: 'string', required: true, max: 100 },
|
|
84
|
+
code: { type: 'string', max: 2 }
|
|
85
|
+
},
|
|
86
|
+
tableName: 'connector_parity_countries'
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
await api.resources.countries.createKnexTable()
|
|
90
|
+
api.http.express.mount(expressApp)
|
|
91
|
+
|
|
92
|
+
return { api, expressApp, fastifyApp }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assertLocationSuffix (location, resourceId) {
|
|
96
|
+
assert.ok(location, 'Expected Location header to be present')
|
|
97
|
+
assert.ok(
|
|
98
|
+
location.endsWith(`/api/countries/${resourceId}`) ||
|
|
99
|
+
location.endsWith(`/countries/${resourceId}`),
|
|
100
|
+
`Unexpected Location header: ${location}`
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('HTTP Connector Parity', () => {
|
|
105
|
+
let api
|
|
106
|
+
let expressApp
|
|
107
|
+
let fastifyApp
|
|
108
|
+
|
|
109
|
+
before(async () => {
|
|
110
|
+
({ api, expressApp, fastifyApp } = await createConnectorParityApi())
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
after(async () => {
|
|
114
|
+
await knex.destroy()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
beforeEach(async () => {
|
|
118
|
+
await cleanTables(knex, ['connector_parity_countries'])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('returns the same create semantics through Express and Fastify', async () => {
|
|
122
|
+
const expressDoc = createJsonApiDocument('countries', {
|
|
123
|
+
name: 'Express Country',
|
|
124
|
+
code: 'EC'
|
|
125
|
+
})
|
|
126
|
+
const fastifyDoc = createJsonApiDocument('countries', {
|
|
127
|
+
name: 'Fastify Country',
|
|
128
|
+
code: 'FC'
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const expressResponse = await request(expressApp)
|
|
132
|
+
.post('/api/countries')
|
|
133
|
+
.set('Content-Type', 'application/vnd.api+json')
|
|
134
|
+
.set('Accept', 'application/vnd.api+json')
|
|
135
|
+
.send(expressDoc)
|
|
136
|
+
|
|
137
|
+
const { reply: fastifyReply } = await invokeFastifyRoute(fastifyApp, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
routeUrl: '/api/countries',
|
|
140
|
+
requestUrl: '/api/countries',
|
|
141
|
+
headers: {
|
|
142
|
+
'content-type': 'application/vnd.api+json',
|
|
143
|
+
accept: 'application/vnd.api+json'
|
|
144
|
+
},
|
|
145
|
+
body: fastifyDoc
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
assert.equal(expressResponse.status, 201)
|
|
149
|
+
assert.equal(fastifyReply.statusCode, 201)
|
|
150
|
+
|
|
151
|
+
assert.equal(expressResponse.body.data.type, 'countries')
|
|
152
|
+
assert.equal(fastifyReply.payload.data.type, 'countries')
|
|
153
|
+
assert.equal(expressResponse.body.data.attributes.name, 'Express Country')
|
|
154
|
+
assert.equal(fastifyReply.payload.data.attributes.name, 'Fastify Country')
|
|
155
|
+
assert.equal(expressResponse.headers['x-connector-test'], 'enabled')
|
|
156
|
+
assert.equal(fastifyReply.headers['x-connector-test'], 'enabled')
|
|
157
|
+
|
|
158
|
+
assertLocationSuffix(expressResponse.headers.location, expressResponse.body.data.id)
|
|
159
|
+
assertLocationSuffix(fastifyReply.headers.Location, fastifyReply.payload.data.id)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('rejects unsupported write content types using each connector transport policy', async () => {
|
|
163
|
+
const expressResponse = await request(expressApp)
|
|
164
|
+
.post('/api/countries')
|
|
165
|
+
.set('Content-Type', 'text/plain')
|
|
166
|
+
.send('invalid payload')
|
|
167
|
+
|
|
168
|
+
const { reply: fastifyReply } = await invokeFastifyRoute(fastifyApp, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
routeUrl: '/api/countries',
|
|
171
|
+
requestUrl: '/api/countries',
|
|
172
|
+
headers: { 'content-type': 'text/plain' },
|
|
173
|
+
body: 'invalid payload'
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
assert.equal(expressResponse.status, 415)
|
|
177
|
+
assert.equal(fastifyReply.statusCode, 415)
|
|
178
|
+
assert.deepEqual(
|
|
179
|
+
expressResponse.body,
|
|
180
|
+
getUnsupportedMediaTypeErrorBody({ allowMultipart: true })
|
|
181
|
+
)
|
|
182
|
+
assert.deepEqual(
|
|
183
|
+
fastifyReply.payload,
|
|
184
|
+
getUnsupportedMediaTypeErrorBody()
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('maps validation errors the same way in both connectors', async () => {
|
|
189
|
+
const invalidDoc = createJsonApiDocument('countries', {
|
|
190
|
+
code: 'IV'
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const expressResponse = await request(expressApp)
|
|
194
|
+
.post('/api/countries')
|
|
195
|
+
.set('Content-Type', 'application/vnd.api+json')
|
|
196
|
+
.set('Accept', 'application/vnd.api+json')
|
|
197
|
+
.send(invalidDoc)
|
|
198
|
+
|
|
199
|
+
const { reply: fastifyReply } = await invokeFastifyRoute(fastifyApp, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
routeUrl: '/api/countries',
|
|
202
|
+
requestUrl: '/api/countries',
|
|
203
|
+
headers: {
|
|
204
|
+
'content-type': 'application/vnd.api+json',
|
|
205
|
+
accept: 'application/vnd.api+json'
|
|
206
|
+
},
|
|
207
|
+
body: invalidDoc
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
assert.equal(expressResponse.status, 422)
|
|
211
|
+
assert.equal(fastifyReply.statusCode, 422)
|
|
212
|
+
assert.deepEqual(fastifyReply.payload, expressResponse.body)
|
|
213
|
+
assert.equal(expressResponse.headers['x-connector-test'], 'enabled')
|
|
214
|
+
assert.equal(fastifyReply.headers['x-connector-test'], 'enabled')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('honors transport request rejection hooks the same way in both connectors', async () => {
|
|
218
|
+
const expressResponse = await request(expressApp)
|
|
219
|
+
.get('/api/countries')
|
|
220
|
+
.set('x-block-request', 'yes')
|
|
221
|
+
|
|
222
|
+
const { reply: fastifyReply } = await invokeFastifyRoute(fastifyApp, {
|
|
223
|
+
method: 'GET',
|
|
224
|
+
routeUrl: '/api/countries',
|
|
225
|
+
requestUrl: '/api/countries',
|
|
226
|
+
headers: { 'x-block-request': 'yes' }
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
assert.equal(expressResponse.status, 401)
|
|
230
|
+
assert.equal(fastifyReply.statusCode, 401)
|
|
231
|
+
assert.deepEqual(fastifyReply.payload, expressResponse.body)
|
|
232
|
+
assert.equal(expressResponse.headers['x-connector-test'], undefined)
|
|
233
|
+
assert.equal(fastifyReply.headers['x-connector-test'], undefined)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { describe, it, before, beforeEach, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Api } from 'hooked-api'
|
|
4
|
+
import knexLib from 'knex'
|
|
5
|
+
import { LabelPlugin, QueryProjectionsPlugin, RestApiKnexPlugin, RestApiPlugin } from '../index.js'
|
|
6
|
+
import {
|
|
7
|
+
cleanTables,
|
|
8
|
+
createJsonApiDocument,
|
|
9
|
+
createRelationship,
|
|
10
|
+
resourceIdentifier,
|
|
11
|
+
validateJsonApiStructure,
|
|
12
|
+
} from './helpers/test-utils.js'
|
|
13
|
+
import { createProjectedFieldsApi } 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
|
+
describe('Query Projections', () => {
|
|
24
|
+
let api
|
|
25
|
+
const testData = {}
|
|
26
|
+
|
|
27
|
+
before(async () => {
|
|
28
|
+
api = await createProjectedFieldsApi(knex)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
after(async () => {
|
|
32
|
+
await knex.destroy()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
await cleanTables(knex, ['projected_books', 'projected_authors'])
|
|
37
|
+
|
|
38
|
+
const authorPayloads = [
|
|
39
|
+
{ first_name: 'Alex', last_name: 'Carter' },
|
|
40
|
+
{ first_name: 'Alex', last_name: 'Carter' },
|
|
41
|
+
{ first_name: 'Bella', last_name: 'Stone' },
|
|
42
|
+
{ first_name: 'Aaron', last_name: 'Zephyr' }
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
testData.authors = []
|
|
46
|
+
for (const payload of authorPayloads) {
|
|
47
|
+
const result = await api.resources.authors.post({
|
|
48
|
+
inputRecord: createJsonApiDocument('authors', payload),
|
|
49
|
+
simplified: false
|
|
50
|
+
})
|
|
51
|
+
testData.authors.push(result.data)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
testData.expectedAuthorOrder = [
|
|
55
|
+
testData.authors[3].id,
|
|
56
|
+
testData.authors[0].id,
|
|
57
|
+
testData.authors[1].id,
|
|
58
|
+
testData.authors[2].id
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const bookOne = await api.resources.books.post({
|
|
62
|
+
inputRecord: createJsonApiDocument(
|
|
63
|
+
'books',
|
|
64
|
+
{ title: 'Derived Fields Handbook' },
|
|
65
|
+
{ author: createRelationship(resourceIdentifier('authors', testData.authors[0].id)) }
|
|
66
|
+
),
|
|
67
|
+
simplified: false
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const bookTwo = await api.resources.books.post({
|
|
71
|
+
inputRecord: createJsonApiDocument(
|
|
72
|
+
'books',
|
|
73
|
+
{ title: 'Cursor Patterns' },
|
|
74
|
+
{ author: createRelationship(resourceIdentifier('authors', testData.authors[2].id)) }
|
|
75
|
+
),
|
|
76
|
+
simplified: false
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
testData.books = [bookOne.data, bookTwo.data]
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
async function createEphemeralProjectionApi (resourceName, resourceOptions) {
|
|
83
|
+
const api = new Api({
|
|
84
|
+
name: `${resourceName}-projection-test-api`,
|
|
85
|
+
log: { level: process.env.LOG_LEVEL || 'info' }
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await api.use(RestApiPlugin, {
|
|
89
|
+
simplifiedApi: true,
|
|
90
|
+
simplifiedTransport: true,
|
|
91
|
+
returnRecordApi: {
|
|
92
|
+
post: true,
|
|
93
|
+
put: true,
|
|
94
|
+
patch: true
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
await api.use(QueryProjectionsPlugin)
|
|
98
|
+
await api.use(RestApiKnexPlugin, { knex })
|
|
99
|
+
await api.addResource(resourceName, resourceOptions)
|
|
100
|
+
|
|
101
|
+
return api
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
it('should return query projection fields by default and ignore query projection input on writes', async () => {
|
|
105
|
+
const created = await api.resources.authors.post({
|
|
106
|
+
inputRecord: createJsonApiDocument('authors', {
|
|
107
|
+
first_name: 'Nina',
|
|
108
|
+
last_name: 'Simone',
|
|
109
|
+
full_name: 'Injected Value'
|
|
110
|
+
}),
|
|
111
|
+
simplified: false
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
validateJsonApiStructure(created)
|
|
115
|
+
assert.equal(created.data.attributes.first_name, 'Nina')
|
|
116
|
+
assert.equal(created.data.attributes.last_name, 'Simone')
|
|
117
|
+
assert.equal(created.data.attributes.full_name, 'Nina Simone')
|
|
118
|
+
|
|
119
|
+
const fetched = await api.resources.authors.get({
|
|
120
|
+
id: created.data.id,
|
|
121
|
+
simplified: false
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
validateJsonApiStructure(fetched)
|
|
125
|
+
assert.equal(fetched.data.attributes.full_name, 'Nina Simone')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should allow query projection fields in sparse fieldsets', async () => {
|
|
129
|
+
const result = await api.resources.authors.get({
|
|
130
|
+
id: testData.authors[0].id,
|
|
131
|
+
queryParams: {
|
|
132
|
+
fields: {
|
|
133
|
+
authors: 'first_name,full_name'
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
simplified: false
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
validateJsonApiStructure(result)
|
|
140
|
+
assert.equal(result.data.attributes.first_name, 'Alex')
|
|
141
|
+
assert.equal(result.data.attributes.full_name, 'Alex Carter')
|
|
142
|
+
assert.equal(result.data.attributes.last_name, undefined)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should expose query projection fields on included resources', async () => {
|
|
146
|
+
const result = await api.resources.books.get({
|
|
147
|
+
id: testData.books[0].id,
|
|
148
|
+
queryParams: {
|
|
149
|
+
include: ['author'],
|
|
150
|
+
fields: {
|
|
151
|
+
books: 'title',
|
|
152
|
+
authors: 'full_name'
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
simplified: false
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
validateJsonApiStructure(result)
|
|
159
|
+
assert.ok(Array.isArray(result.included))
|
|
160
|
+
|
|
161
|
+
const includedAuthor = result.included.find((resource) => resource.type === 'authors')
|
|
162
|
+
assert.ok(includedAuthor, 'expected included author resource')
|
|
163
|
+
assert.equal(includedAuthor.attributes.full_name, 'Alex Carter')
|
|
164
|
+
assert.equal(includedAuthor.attributes.first_name, undefined)
|
|
165
|
+
assert.equal(includedAuthor.attributes.last_name, undefined)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should support stable cursor pagination when sorting by a query projection field', async () => {
|
|
169
|
+
const seenIds = []
|
|
170
|
+
const seenFirstNames = []
|
|
171
|
+
let nextCursor = null
|
|
172
|
+
let iterations = 0
|
|
173
|
+
let firstCursor = null
|
|
174
|
+
|
|
175
|
+
while (iterations < 10) {
|
|
176
|
+
const result = await api.resources.authors.query({
|
|
177
|
+
queryParams: {
|
|
178
|
+
sort: ['full_name'],
|
|
179
|
+
fields: {
|
|
180
|
+
authors: 'first_name'
|
|
181
|
+
},
|
|
182
|
+
page: {
|
|
183
|
+
size: 1,
|
|
184
|
+
...(nextCursor ? { after: nextCursor } : {})
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
simplified: false
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
validateJsonApiStructure(result, true)
|
|
191
|
+
assert.equal(result.data.length, 1)
|
|
192
|
+
|
|
193
|
+
const resource = result.data[0]
|
|
194
|
+
seenIds.push(resource.id)
|
|
195
|
+
seenFirstNames.push(resource.attributes.first_name)
|
|
196
|
+
assert.equal(resource.attributes.full_name, undefined)
|
|
197
|
+
|
|
198
|
+
if (!firstCursor && result.meta?.pagination?.cursor?.next) {
|
|
199
|
+
firstCursor = result.meta.pagination.cursor.next
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!result.meta?.pagination?.hasMore) {
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
nextCursor = result.meta.pagination.cursor.next
|
|
207
|
+
iterations += 1
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
assert.deepEqual(seenIds, testData.expectedAuthorOrder)
|
|
211
|
+
assert.equal(new Set(seenIds).size, seenIds.length, 'cursor pagination should not duplicate records')
|
|
212
|
+
assert.ok(firstCursor?.includes('full_name:'), 'cursor should encode query projection sort field')
|
|
213
|
+
assert.ok(firstCursor?.includes('id:'), 'cursor should append id as deterministic tie-breaker')
|
|
214
|
+
assert.deepEqual(seenFirstNames, ['Aaron', 'Alex', 'Alex', 'Bella'])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should allow sorting by a normallyHidden query projection field without returning it by default', async () => {
|
|
218
|
+
const result = await api.resources.authors.query({
|
|
219
|
+
queryParams: {
|
|
220
|
+
sort: ['sort_name']
|
|
221
|
+
},
|
|
222
|
+
simplified: false
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
validateJsonApiStructure(result, true)
|
|
226
|
+
assert.deepEqual(
|
|
227
|
+
result.data.map((resource) => resource.id),
|
|
228
|
+
testData.expectedAuthorOrder
|
|
229
|
+
)
|
|
230
|
+
result.data.forEach((resource) => {
|
|
231
|
+
assert.equal(resource.attributes.sort_name, undefined)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should reject query projection expressions that return plain SQL strings', async () => {
|
|
236
|
+
const stringApi = await createEphemeralProjectionApi('string_projection_authors', {
|
|
237
|
+
schema: {
|
|
238
|
+
id: { type: 'id' },
|
|
239
|
+
first_name: { type: 'string', required: true },
|
|
240
|
+
last_name: { type: 'string', required: true }
|
|
241
|
+
},
|
|
242
|
+
queryFields: {
|
|
243
|
+
full_name: {
|
|
244
|
+
type: 'string',
|
|
245
|
+
select: () => "trim(coalesce(first_name, '') || ' ' || coalesce(last_name, ''))"
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
tableName: 'projected_authors_string_expr'
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
await stringApi.resources.string_projection_authors.createKnexTable()
|
|
252
|
+
await knex('projected_authors_string_expr').insert({
|
|
253
|
+
first_name: 'Nina',
|
|
254
|
+
last_name: 'Simone'
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
await assert.rejects(
|
|
258
|
+
() => stringApi.resources.string_projection_authors.query({
|
|
259
|
+
queryParams: {},
|
|
260
|
+
simplified: false
|
|
261
|
+
}),
|
|
262
|
+
/Query field expressions must return knex raw, knex ref, or a knex query builder/
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should reject query projection names that conflict with relationship names', async () => {
|
|
267
|
+
await assert.rejects(
|
|
268
|
+
() => createEphemeralProjectionApi('conflicting_projection_authors', {
|
|
269
|
+
schema: {
|
|
270
|
+
id: { type: 'id' },
|
|
271
|
+
name: { type: 'string', required: true }
|
|
272
|
+
},
|
|
273
|
+
queryFields: {
|
|
274
|
+
books: {
|
|
275
|
+
type: 'string',
|
|
276
|
+
select: ({ knex }) => knex.raw("'X'")
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
relationships: {
|
|
280
|
+
books: { type: 'hasMany', target: 'books', foreignKey: 'author_id' }
|
|
281
|
+
},
|
|
282
|
+
tableName: 'projected_authors_conflict'
|
|
283
|
+
}),
|
|
284
|
+
/conflicts with an existing schema/
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should reject query projection names that conflict with computed fields injected by plugins', async () => {
|
|
289
|
+
const labeledApi = new Api({
|
|
290
|
+
name: 'label-projection-conflict-api',
|
|
291
|
+
log: { level: process.env.LOG_LEVEL || 'info' }
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
await labeledApi.use(RestApiPlugin, {
|
|
295
|
+
simplifiedApi: true,
|
|
296
|
+
simplifiedTransport: true
|
|
297
|
+
})
|
|
298
|
+
await labeledApi.use(LabelPlugin)
|
|
299
|
+
await labeledApi.use(QueryProjectionsPlugin)
|
|
300
|
+
await labeledApi.use(RestApiKnexPlugin, { knex })
|
|
301
|
+
|
|
302
|
+
await assert.rejects(
|
|
303
|
+
() => labeledApi.addResource('labeled_projection_authors', {
|
|
304
|
+
schema: {
|
|
305
|
+
id: { type: 'id' },
|
|
306
|
+
name: { type: 'string', required: true }
|
|
307
|
+
},
|
|
308
|
+
queryFields: {
|
|
309
|
+
label: {
|
|
310
|
+
type: 'string',
|
|
311
|
+
select: ({ knex }) => knex.raw("'X'")
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
tableName: 'projected_authors_label_conflict'
|
|
315
|
+
}),
|
|
316
|
+
/conflicts with an existing schema, computed, or relationship name/
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
})
|
|
@@ -330,7 +330,6 @@ describe('Relationship Operations', () => {
|
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
332
|
}
|
|
333
|
-
console.log('THE NEXT LINE WILL BLOCK THE TEST')
|
|
334
333
|
await basicApi.resources.books.put({
|
|
335
334
|
id: bookResult.data.id,
|
|
336
335
|
inputRecord: putDoc,
|
|
@@ -590,7 +589,7 @@ describe('Relationship Operations', () => {
|
|
|
590
589
|
assert(publisher.relationships.country)
|
|
591
590
|
})
|
|
592
591
|
|
|
593
|
-
it('should
|
|
592
|
+
it('should clear omitted relationships when PUT provides a relationships object', async () => {
|
|
594
593
|
// Create resources
|
|
595
594
|
const countryDoc = createJsonApiDocument('countries', { name: 'USA', code: 'US' })
|
|
596
595
|
const countryResult = await basicApi.resources.countries.post({
|
|
@@ -640,7 +639,8 @@ describe('Relationship Operations', () => {
|
|
|
640
639
|
simplified: false
|
|
641
640
|
})
|
|
642
641
|
|
|
643
|
-
// PUT with only publisher relationship
|
|
642
|
+
// PUT with only publisher relationship. Omitted relationships are cleared
|
|
643
|
+
// once a relationships object is provided for a complete replacement.
|
|
644
644
|
const putDoc = {
|
|
645
645
|
data: {
|
|
646
646
|
type: 'books',
|