json-rest-api 1.0.25 → 1.0.27
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/docs/API.md +5 -0
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +2 -0
- package/index.js +1 -0
- package/lib/error-context.js +21 -0
- package/lib/rest-api-errors.js +35 -0
- package/package.json +1 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +10 -0
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +36 -26
- package/plugins/core/lib/querying/knex-process-includes.js +11 -5
- package/plugins/core/lib/querying/knex-relationship-includes.js +45 -39
- package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -1
- package/tests/computed-fields.test.js +69 -2
- package/tests/fixtures/api-configs.js +26 -5
- package/tests/includes.test.js +49 -0
- package/tests/row-policy.test.js +62 -6
package/docs/API.md
CHANGED
|
@@ -2052,6 +2052,11 @@ const result = await api.resources.articles.query({
|
|
|
2052
2052
|
// GET /articles?fields[articles]=title,summary&fields[users]=name,avatar
|
|
2053
2053
|
```
|
|
2054
2054
|
|
|
2055
|
+
An unknown field raises `RestApiFieldsetError` with the stable code
|
|
2056
|
+
`REST_API_FIELDSET_INVALID` and HTTP status `400`. The same error is preserved
|
|
2057
|
+
for primary resources and included relationships, so transports can classify it
|
|
2058
|
+
without parsing error messages.
|
|
2059
|
+
|
|
2055
2060
|
### Including Related Resources
|
|
2056
2061
|
|
|
2057
2062
|
Load related resources in a single request:
|
|
@@ -204,6 +204,8 @@ column('id', { scopeName: 'users', alias: 'visible_users' })
|
|
|
204
204
|
column('status', { alias: null }) // translated but deliberately unqualified
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
+
The active resource's default qualifier and storage adapter are fixed when the policy hook begins. Calling `column()` later from a Knex grouping callback therefore still targets the table or alias of that query, even after the surrounding hook has returned. This applies to normal Knex and AnyAPI queries. A `scopeName` option resolves that target resource through the storage registry; use the explicit `alias` option only when the policy itself introduces another query alias.
|
|
208
|
+
|
|
207
209
|
`value()` accepts the same `scopeName` option:
|
|
208
210
|
|
|
209
211
|
```js
|
package/index.js
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { HookedApiError } from 'hooked-api'
|
|
2
|
+
|
|
3
|
+
export function isRestApiError (error) {
|
|
4
|
+
return error instanceof HookedApiError
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Add operational context to unexpected failures without erasing typed API
|
|
9
|
+
* errors that callers and transports need to classify.
|
|
10
|
+
*/
|
|
11
|
+
export function wrapUnexpectedError (error, { message, context } = {}) {
|
|
12
|
+
if (isRestApiError(error)) {
|
|
13
|
+
return error
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const wrappedError = new Error(`${message}: ${error.message}`, {
|
|
17
|
+
cause: error
|
|
18
|
+
})
|
|
19
|
+
wrappedError.context = context
|
|
20
|
+
return wrappedError
|
|
21
|
+
}
|
package/lib/rest-api-errors.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { HookedApiError } from 'hooked-api'
|
|
2
2
|
|
|
3
|
+
export const REST_API_FIELDSET_ERROR_CODE = 'REST_API_FIELDSET_INVALID'
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* REST API specific error classes that can be mapped to HTTP status codes
|
|
5
7
|
* by the HTTP plugin or other protocol plugins
|
|
@@ -90,3 +92,36 @@ export class RestApiPayloadError extends HookedApiError {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Invalid JSON:API sparse fieldset.
|
|
98
|
+
*
|
|
99
|
+
* The structured code lets transports distinguish invalid client input from
|
|
100
|
+
* query or include failures without parsing the human-readable message.
|
|
101
|
+
*/
|
|
102
|
+
export class RestApiFieldsetError extends HookedApiError {
|
|
103
|
+
constructor ({ field, resourceType } = {}) {
|
|
104
|
+
super(
|
|
105
|
+
`Unknown sparse field '${field}' requested for '${resourceType}'`,
|
|
106
|
+
REST_API_FIELDSET_ERROR_CODE
|
|
107
|
+
)
|
|
108
|
+
this.type = 'rest_api_fieldset'
|
|
109
|
+
this.statusCode = 400
|
|
110
|
+
this.details = {
|
|
111
|
+
field,
|
|
112
|
+
resourceType
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
toJSON () {
|
|
117
|
+
return {
|
|
118
|
+
name: this.name,
|
|
119
|
+
message: this.message,
|
|
120
|
+
code: this.code,
|
|
121
|
+
type: this.type,
|
|
122
|
+
statusCode: this.statusCode,
|
|
123
|
+
details: this.details,
|
|
124
|
+
stack: this.stack
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { REST_API_FIELDSET_ERROR_CODE } from '../../../../lib/rest-api-errors.js'
|
|
2
|
+
|
|
1
3
|
const JSON_API_WRITE_CONTENT_TYPES = [
|
|
2
4
|
'application/vnd.api+json',
|
|
3
5
|
'application/json'
|
|
@@ -63,6 +65,14 @@ export function mapRestApiErrorToHttp (error) {
|
|
|
63
65
|
detail: issue.message,
|
|
64
66
|
...(issue.instancePath ? { source: { pointer: issue.instancePath } } : {})
|
|
65
67
|
}))
|
|
68
|
+
} else if (error.code === REST_API_FIELDSET_ERROR_CODE) {
|
|
69
|
+
status = 400
|
|
70
|
+
errors = [{
|
|
71
|
+
status: '400',
|
|
72
|
+
code: error.code,
|
|
73
|
+
title: 'Invalid Sparse Fieldset',
|
|
74
|
+
detail: error.message
|
|
75
|
+
}]
|
|
66
76
|
} else if (error.code === 'REST_API_VALIDATION') {
|
|
67
77
|
status = 422
|
|
68
78
|
if (error.details?.violations?.length) {
|
|
@@ -71,14 +71,6 @@ const SELECT_METHODS = new Set(['select', 'columns', 'distinct'])
|
|
|
71
71
|
|
|
72
72
|
const AGGREGATE_METHODS = new Set(['count', 'min', 'max', 'sum', 'avg'])
|
|
73
73
|
|
|
74
|
-
const CALLBACK_FIRST_METHODS = new Set([
|
|
75
|
-
'where',
|
|
76
|
-
'andWhere',
|
|
77
|
-
'orWhere',
|
|
78
|
-
'having',
|
|
79
|
-
'orHaving',
|
|
80
|
-
])
|
|
81
|
-
|
|
82
74
|
const isFunction = (value) => typeof value === 'function'
|
|
83
75
|
const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
84
76
|
|
|
@@ -149,13 +141,6 @@ const translateJoinObject = (adapter, obj) => {
|
|
|
149
141
|
return result
|
|
150
142
|
}
|
|
151
143
|
|
|
152
|
-
const maybeWrapCallback = (adapter, callback) => {
|
|
153
|
-
if (!isFunction(callback)) return callback
|
|
154
|
-
return function wrappedCallback (...args) {
|
|
155
|
-
return callback.apply(adapter.proxy, args)
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
144
|
const isRaw = (value) => Boolean(value && typeof value === 'object' && typeof value.toSQL === 'function' && value.toSQL().method)
|
|
160
145
|
|
|
161
146
|
const ensureArray = (value) => (Array.isArray(value) ? value : [value])
|
|
@@ -317,12 +302,39 @@ export class AnyapiQueryAdapter {
|
|
|
317
302
|
this.aliasActualNames.set(alias, alias)
|
|
318
303
|
}
|
|
319
304
|
|
|
320
|
-
#
|
|
305
|
+
#wrapQueryCallback (callback) {
|
|
306
|
+
if (!isFunction(callback)) return callback
|
|
307
|
+
const adapter = this
|
|
308
|
+
|
|
309
|
+
return function wrappedCallback (...args) {
|
|
310
|
+
const callbackTarget = this
|
|
311
|
+
const callbackProxy = adapter.#createProxy(callbackTarget)
|
|
312
|
+
const callbackArgs = args.map((arg) => arg === callbackTarget ? callbackProxy : arg)
|
|
313
|
+
return callback.apply(callbackProxy, callbackArgs)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#wrapJoinCallback (callback) {
|
|
318
|
+
if (!isFunction(callback)) return callback
|
|
319
|
+
const adapter = this
|
|
320
|
+
|
|
321
|
+
return function wrappedCallback (...args) {
|
|
322
|
+
const callbackTarget = this
|
|
323
|
+
const callbackProxy = adapter.#createJoinProxy(callbackTarget)
|
|
324
|
+
const callbackArgs = args.map((arg) => arg === callbackTarget ? callbackProxy : arg)
|
|
325
|
+
return callback.apply(callbackProxy, callbackArgs)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#translateWhereArgs (args, { joinClause = false } = {}) {
|
|
321
330
|
if (args.length === 0) return args
|
|
322
331
|
const [first, ...rest] = args
|
|
323
332
|
|
|
324
333
|
if (isFunction(first)) {
|
|
325
|
-
|
|
334
|
+
const wrappedGroup = joinClause
|
|
335
|
+
? this.#wrapJoinCallback(first)
|
|
336
|
+
: this.#wrapQueryCallback(first)
|
|
337
|
+
return [wrappedGroup, ...rest]
|
|
326
338
|
}
|
|
327
339
|
|
|
328
340
|
if (isObject(first) && !isRaw(first)) {
|
|
@@ -337,7 +349,7 @@ export class AnyapiQueryAdapter {
|
|
|
337
349
|
|
|
338
350
|
if (rest.length > 0 && isFunction(rest[rest.length - 1])) {
|
|
339
351
|
const newRest = [...rest]
|
|
340
|
-
newRest[newRest.length - 1] =
|
|
352
|
+
newRest[newRest.length - 1] = this.#wrapQueryCallback(rest[rest.length - 1])
|
|
341
353
|
return [translatedFirst, ...newRest]
|
|
342
354
|
}
|
|
343
355
|
|
|
@@ -350,7 +362,7 @@ export class AnyapiQueryAdapter {
|
|
|
350
362
|
const translatedColumn = this.translateColumn(column)
|
|
351
363
|
|
|
352
364
|
if (isFunction(values)) {
|
|
353
|
-
return [translatedColumn,
|
|
365
|
+
return [translatedColumn, this.#wrapQueryCallback(values), ...rest]
|
|
354
366
|
}
|
|
355
367
|
|
|
356
368
|
return [translatedColumn, values, ...rest]
|
|
@@ -439,7 +451,7 @@ export class AnyapiQueryAdapter {
|
|
|
439
451
|
const [first, ...rest] = args
|
|
440
452
|
|
|
441
453
|
if (isFunction(first)) {
|
|
442
|
-
return [
|
|
454
|
+
return [this.#wrapJoinCallback(first), ...rest]
|
|
443
455
|
}
|
|
444
456
|
|
|
445
457
|
if (isObject(first) && !isRaw(first)) {
|
|
@@ -454,7 +466,7 @@ export class AnyapiQueryAdapter {
|
|
|
454
466
|
|
|
455
467
|
const translatedRest = rest.map((value, index) => {
|
|
456
468
|
if (isFunction(value)) {
|
|
457
|
-
return
|
|
469
|
+
return this.#wrapJoinCallback(value)
|
|
458
470
|
}
|
|
459
471
|
if (typeof value !== 'string') {
|
|
460
472
|
return value
|
|
@@ -475,7 +487,7 @@ export class AnyapiQueryAdapter {
|
|
|
475
487
|
const translatedColumn = typeof column === 'string' ? this.translateColumn(column) : column
|
|
476
488
|
|
|
477
489
|
if (isFunction(values)) {
|
|
478
|
-
return [translatedColumn,
|
|
490
|
+
return [translatedColumn, this.#wrapQueryCallback(values), ...rest]
|
|
479
491
|
}
|
|
480
492
|
|
|
481
493
|
return [translatedColumn, values, ...rest]
|
|
@@ -510,7 +522,7 @@ export class AnyapiQueryAdapter {
|
|
|
510
522
|
let translatedArgs = args
|
|
511
523
|
|
|
512
524
|
if (WHERE_METHODS.has(prop)) {
|
|
513
|
-
translatedArgs = adapter.#translateWhereArgs(
|
|
525
|
+
translatedArgs = adapter.#translateWhereArgs(args, { joinClause: true })
|
|
514
526
|
} else if (WHERE_IN_METHODS.has(prop)) {
|
|
515
527
|
translatedArgs = adapter.#translateWhereInArgs(args)
|
|
516
528
|
} else if (BETWEEN_METHODS.has(prop)) {
|
|
@@ -607,7 +619,7 @@ export class AnyapiQueryAdapter {
|
|
|
607
619
|
let translatedArgs = args
|
|
608
620
|
|
|
609
621
|
if (WHERE_METHODS.has(prop)) {
|
|
610
|
-
translatedArgs = adapter.#translateWhereArgs(
|
|
622
|
+
translatedArgs = adapter.#translateWhereArgs(args)
|
|
611
623
|
} else if (WHERE_IN_METHODS.has(prop)) {
|
|
612
624
|
translatedArgs = adapter.#translateWhereInArgs(args)
|
|
613
625
|
} else if (BETWEEN_METHODS.has(prop)) {
|
|
@@ -630,8 +642,6 @@ export class AnyapiQueryAdapter {
|
|
|
630
642
|
translatedArgs = adapter.#translateSelectArgs(args)
|
|
631
643
|
} else if (AGGREGATE_METHODS.has(prop)) {
|
|
632
644
|
translatedArgs = adapter.#translateAggregateArgs(args)
|
|
633
|
-
} else if (CALLBACK_FIRST_METHODS.has(prop) && args.length && isFunction(args[0])) {
|
|
634
|
-
translatedArgs = [maybeWrapCallback(adapter, args[0]), ...args.slice(1)]
|
|
635
645
|
}
|
|
636
646
|
|
|
637
647
|
const result = value.apply(target, translatedArgs)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildIncludedResources } from './knex-relationship-includes.js'
|
|
2
|
+
import { isRestApiError, wrapUnexpectedError } from '../../../../lib/error-context.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Processes the ?include= parameter to load related resources efficiently
|
|
@@ -143,6 +144,8 @@ export const processIncludes = async (scope, records, deps) => {
|
|
|
143
144
|
|
|
144
145
|
return includeResult.included
|
|
145
146
|
} catch (error) {
|
|
147
|
+
if (isRestApiError(error)) throw error
|
|
148
|
+
|
|
146
149
|
const { log, context } = deps || {}
|
|
147
150
|
|
|
148
151
|
if (log) {
|
|
@@ -157,10 +160,13 @@ export const processIncludes = async (scope, records, deps) => {
|
|
|
157
160
|
console.error('[PROCESS-INCLUDES] Error processing includes:', error)
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
|
|
161
|
-
`Failed to process includes${context?.scopeName ? ` for scope '${context.scopeName}'` : ''}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
throw wrapUnexpectedError(error, {
|
|
164
|
+
message: `Failed to process includes${context?.scopeName ? ` for scope '${context.scopeName}'` : ''}`,
|
|
165
|
+
context: {
|
|
166
|
+
scopeName: context?.scopeName,
|
|
167
|
+
includeParam: context?.queryParams?.include,
|
|
168
|
+
recordCount: records?.length || 0
|
|
169
|
+
}
|
|
170
|
+
})
|
|
165
171
|
}
|
|
166
172
|
}
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
import { unwrapQueryBuilderState } from './query-builder-utils.js'
|
|
85
85
|
import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js'
|
|
86
86
|
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
87
|
+
import { isRestApiError, wrapUnexpectedError } from '../../../../lib/error-context.js'
|
|
87
88
|
import {
|
|
88
89
|
getFieldValue as getStorageFieldValue,
|
|
89
90
|
getIdColumn as getStorageIdColumn,
|
|
@@ -415,14 +416,18 @@ const loadRelationshipMetadata = async (scopes, records, scopeName) => {
|
|
|
415
416
|
}
|
|
416
417
|
})
|
|
417
418
|
} catch (error) {
|
|
418
|
-
|
|
419
|
+
if (isRestApiError(error)) throw error
|
|
420
|
+
|
|
419
421
|
const errorContext = {
|
|
420
422
|
scopeName,
|
|
421
423
|
recordCount: records?.length || 0,
|
|
422
424
|
error: error.message
|
|
423
425
|
}
|
|
424
426
|
console.error('[loadRelationshipMetadata] Error loading relationship metadata:', errorContext)
|
|
425
|
-
throw
|
|
427
|
+
throw wrapUnexpectedError(error, {
|
|
428
|
+
message: `Failed to load relationship metadata for scope '${scopeName}'`,
|
|
429
|
+
context: errorContext
|
|
430
|
+
})
|
|
426
431
|
}
|
|
427
432
|
}
|
|
428
433
|
|
|
@@ -626,6 +631,8 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
626
631
|
}
|
|
627
632
|
}
|
|
628
633
|
} catch (error) {
|
|
634
|
+
if (isRestApiError(error)) throw error
|
|
635
|
+
|
|
629
636
|
// Log error with detailed context
|
|
630
637
|
log.error('[INCLUDE] Error loading belongsTo relationship:', {
|
|
631
638
|
fieldName,
|
|
@@ -636,13 +643,10 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
636
643
|
stack: error.stack
|
|
637
644
|
})
|
|
638
645
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
)
|
|
643
|
-
enhancedError.originalError = error
|
|
644
|
-
enhancedError.context = { fieldName, includeName, targetScope: fieldDef.belongsTo }
|
|
645
|
-
throw enhancedError
|
|
646
|
+
throw wrapUnexpectedError(error, {
|
|
647
|
+
message: `Failed to load belongsTo relationship '${includeName}' for field '${fieldName}'`,
|
|
648
|
+
context: { fieldName, includeName, targetScope: fieldDef.belongsTo }
|
|
649
|
+
})
|
|
646
650
|
}
|
|
647
651
|
}
|
|
648
652
|
|
|
@@ -1159,6 +1163,8 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
1159
1163
|
}
|
|
1160
1164
|
}
|
|
1161
1165
|
} catch (error) {
|
|
1166
|
+
if (isRestApiError(error)) throw error
|
|
1167
|
+
|
|
1162
1168
|
// Log error with detailed context
|
|
1163
1169
|
log.error('[INCLUDE] Error loading hasMany relationship:', {
|
|
1164
1170
|
scopeName,
|
|
@@ -1169,13 +1175,10 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
1169
1175
|
stack: error.stack
|
|
1170
1176
|
})
|
|
1171
1177
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
)
|
|
1176
|
-
enhancedError.originalError = error
|
|
1177
|
-
enhancedError.context = { scopeName, includeName, hasThrough: !!relDef.through }
|
|
1178
|
-
throw enhancedError
|
|
1178
|
+
throw wrapUnexpectedError(error, {
|
|
1179
|
+
message: `Failed to load hasMany relationship '${includeName}' for scope '${scopeName}'`,
|
|
1180
|
+
context: { scopeName, includeName, hasThrough: !!relDef.through }
|
|
1181
|
+
})
|
|
1179
1182
|
}
|
|
1180
1183
|
}
|
|
1181
1184
|
|
|
@@ -1539,6 +1542,8 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
1539
1542
|
}
|
|
1540
1543
|
})
|
|
1541
1544
|
} catch (error) {
|
|
1545
|
+
if (isRestApiError(error)) throw error
|
|
1546
|
+
|
|
1542
1547
|
// Log error with detailed context
|
|
1543
1548
|
log.error('[INCLUDE] Error loading polymorphic belongsTo relationship:', {
|
|
1544
1549
|
relName,
|
|
@@ -1548,13 +1553,10 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
1548
1553
|
stack: error.stack
|
|
1549
1554
|
})
|
|
1550
1555
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
)
|
|
1555
|
-
enhancedError.originalError = error
|
|
1556
|
-
enhancedError.context = { relName, types: relDef?.belongsToPolymorphic?.types }
|
|
1557
|
-
throw enhancedError
|
|
1556
|
+
throw wrapUnexpectedError(error, {
|
|
1557
|
+
message: `Failed to load polymorphic belongsTo relationship '${relName}'`,
|
|
1558
|
+
context: { relName, types: relDef?.belongsToPolymorphic?.types }
|
|
1559
|
+
})
|
|
1558
1560
|
}
|
|
1559
1561
|
}
|
|
1560
1562
|
|
|
@@ -1759,6 +1761,8 @@ export const loadReversePolymorphic = async (scope, deps) => {
|
|
|
1759
1761
|
}
|
|
1760
1762
|
}
|
|
1761
1763
|
} catch (error) {
|
|
1764
|
+
if (isRestApiError(error)) throw error
|
|
1765
|
+
|
|
1762
1766
|
// Log error with detailed context
|
|
1763
1767
|
log.error('[INCLUDE] Error loading reverse polymorphic relationship:', {
|
|
1764
1768
|
scopeName,
|
|
@@ -1770,13 +1774,10 @@ export const loadReversePolymorphic = async (scope, deps) => {
|
|
|
1770
1774
|
stack: error.stack
|
|
1771
1775
|
})
|
|
1772
1776
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
)
|
|
1777
|
-
enhancedError.originalError = error
|
|
1778
|
-
enhancedError.context = { scopeName, includeName, via: relDef?.via, targetScope: relDef?.target }
|
|
1779
|
-
throw enhancedError
|
|
1777
|
+
throw wrapUnexpectedError(error, {
|
|
1778
|
+
message: `Failed to load reverse polymorphic relationship '${includeName}' via '${relDef?.via}' for scope '${scopeName}'`,
|
|
1779
|
+
context: { scopeName, includeName, via: relDef?.via, targetScope: relDef?.target }
|
|
1780
|
+
})
|
|
1780
1781
|
}
|
|
1781
1782
|
}
|
|
1782
1783
|
|
|
@@ -1897,6 +1898,8 @@ export const processIncludes = async (scope, deps) => {
|
|
|
1897
1898
|
}
|
|
1898
1899
|
}
|
|
1899
1900
|
} catch (includeError) {
|
|
1901
|
+
if (isRestApiError(includeError)) throw includeError
|
|
1902
|
+
|
|
1900
1903
|
// Log specific include error and continue with other includes
|
|
1901
1904
|
log.error('[INCLUDE] Error processing include:', {
|
|
1902
1905
|
scopeName,
|
|
@@ -1918,6 +1921,8 @@ export const processIncludes = async (scope, deps) => {
|
|
|
1918
1921
|
}
|
|
1919
1922
|
}
|
|
1920
1923
|
} catch (error) {
|
|
1924
|
+
if (isRestApiError(error)) throw error
|
|
1925
|
+
|
|
1921
1926
|
// Log error with full context
|
|
1922
1927
|
log.error('[INCLUDE] Error in processIncludes:', {
|
|
1923
1928
|
scopeName,
|
|
@@ -2019,6 +2024,8 @@ export const buildIncludedResources = async (scope, deps) => {
|
|
|
2019
2024
|
recordsWithRelationships: records
|
|
2020
2025
|
}
|
|
2021
2026
|
} catch (error) {
|
|
2027
|
+
if (isRestApiError(error)) throw error
|
|
2028
|
+
|
|
2022
2029
|
// Log comprehensive error information
|
|
2023
2030
|
log.error('[INCLUDE] Failed to build included resources:', {
|
|
2024
2031
|
scopeName,
|
|
@@ -2028,15 +2035,14 @@ export const buildIncludedResources = async (scope, deps) => {
|
|
|
2028
2035
|
stack: error.stack
|
|
2029
2036
|
})
|
|
2030
2037
|
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
}
|
|
2039
|
-
throw enhancedError
|
|
2038
|
+
throw wrapUnexpectedError(error, {
|
|
2039
|
+
message: `Failed to build included resources for scope '${scopeName}'`,
|
|
2040
|
+
context: {
|
|
2041
|
+
scopeName,
|
|
2042
|
+
includeParam,
|
|
2043
|
+
recordCount: records?.length || 0
|
|
2044
|
+
}
|
|
2045
|
+
})
|
|
2040
2046
|
}
|
|
2041
2047
|
}
|
|
2042
2048
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter } = {}) => {
|
|
2
2
|
const context = hookParams?.context || {}
|
|
3
|
+
const activeQuery = context.knexQuery || {}
|
|
4
|
+
const activeScopeName = activeQuery.scopeName
|
|
5
|
+
const activeTableName = activeQuery.tableName
|
|
6
|
+
const activeStorageAdapter = activeQuery.storageAdapter || context.storageAdapter
|
|
3
7
|
const storageCache = new Map()
|
|
4
8
|
|
|
5
9
|
const fetchStorageAdapter = (scopeName) => {
|
|
@@ -7,13 +11,9 @@ export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter }
|
|
|
7
11
|
if (storageCache.has(scopeName)) return storageCache.get(scopeName)
|
|
8
12
|
|
|
9
13
|
let adapter = null
|
|
10
|
-
if (scopeName ===
|
|
11
|
-
adapter =
|
|
12
|
-
context.knexQuery?.storageAdapter ||
|
|
14
|
+
if (scopeName === activeScopeName) {
|
|
15
|
+
adapter = activeStorageAdapter ||
|
|
13
16
|
(typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null)
|
|
14
|
-
if (adapter && !context.storageAdapter) {
|
|
15
|
-
context.storageAdapter = adapter
|
|
16
|
-
}
|
|
17
17
|
} else {
|
|
18
18
|
adapter = typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null
|
|
19
19
|
}
|
|
@@ -23,8 +23,8 @@ export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter }
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const defaultAliasForScope = (scopeName) => {
|
|
26
|
-
if (scopeName ===
|
|
27
|
-
return
|
|
26
|
+
if (scopeName === activeScopeName) {
|
|
27
|
+
return activeTableName || scopeName
|
|
28
28
|
}
|
|
29
29
|
return scopeName
|
|
30
30
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildQuerySelection } from '../querying/knex-query-helpers-base.js'
|
|
2
2
|
import { createSelectTranslator } from '../storage/storage-adapter.js'
|
|
3
3
|
import { buildQueryFieldRuntimes } from './query-field-helpers.js'
|
|
4
|
+
import { RestApiFieldsetError } from '../../../../lib/rest-api-errors.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Checks if a field exists only in code, not in the database
|
|
@@ -211,7 +212,12 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
211
212
|
if (nonDatabaseFields.has(field)) return
|
|
212
213
|
|
|
213
214
|
const fieldDef = schemaStructure[field]
|
|
214
|
-
if (!fieldDef)
|
|
215
|
+
if (!fieldDef) {
|
|
216
|
+
throw new RestApiFieldsetError({
|
|
217
|
+
field,
|
|
218
|
+
resourceType: scopeName
|
|
219
|
+
})
|
|
220
|
+
}
|
|
215
221
|
|
|
216
222
|
if (fieldDef.belongsToPolymorphic) {
|
|
217
223
|
return
|
|
@@ -4,6 +4,23 @@ import knexLib from 'knex'
|
|
|
4
4
|
import { cleanTables } from './helpers/test-utils.js'
|
|
5
5
|
import { storageMode } from './helpers/storage-mode.js'
|
|
6
6
|
import { createComputedFieldsApi } from './fixtures/api-configs.js'
|
|
7
|
+
import {
|
|
8
|
+
REST_API_FIELDSET_ERROR_CODE,
|
|
9
|
+
RestApiFieldsetError
|
|
10
|
+
} from '../index.js'
|
|
11
|
+
import { mapRestApiErrorToHttp } from '../plugins/core/connectors/lib/transport-http-helpers.js'
|
|
12
|
+
|
|
13
|
+
function assertFieldsetError (error, { field, resourceType }) {
|
|
14
|
+
assert(error instanceof RestApiFieldsetError)
|
|
15
|
+
assert.equal(error.code, REST_API_FIELDSET_ERROR_CODE)
|
|
16
|
+
assert.equal(error.statusCode, 400)
|
|
17
|
+
assert.deepEqual(error.details, { field, resourceType })
|
|
18
|
+
assert.equal(
|
|
19
|
+
error.message,
|
|
20
|
+
`Unknown sparse field '${field}' requested for '${resourceType}'`
|
|
21
|
+
)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
// Create Knex instance for tests
|
|
9
26
|
const knex = knexLib({
|
|
@@ -365,7 +382,10 @@ describe('Computed Fields and Sparse Fieldsets', () => {
|
|
|
365
382
|
fields: { products: 'name,unknown_field' }
|
|
366
383
|
}
|
|
367
384
|
}),
|
|
368
|
-
|
|
385
|
+
(error) => assertFieldsetError(error, {
|
|
386
|
+
field: 'unknown_field',
|
|
387
|
+
resourceType: 'products'
|
|
388
|
+
})
|
|
369
389
|
)
|
|
370
390
|
})
|
|
371
391
|
|
|
@@ -377,9 +397,56 @@ describe('Computed Fields and Sparse Fieldsets', () => {
|
|
|
377
397
|
fields: { products: 'name,reviews_ids' }
|
|
378
398
|
}
|
|
379
399
|
}),
|
|
380
|
-
|
|
400
|
+
(error) => assertFieldsetError(error, {
|
|
401
|
+
field: 'reviews_ids',
|
|
402
|
+
resourceType: 'products'
|
|
403
|
+
})
|
|
381
404
|
)
|
|
382
405
|
})
|
|
406
|
+
|
|
407
|
+
it('should preserve fieldset errors from empty included relationships', async () => {
|
|
408
|
+
const productWithoutReviews = await api.resources.products.post({
|
|
409
|
+
name: 'No Reviews',
|
|
410
|
+
price: 25,
|
|
411
|
+
cost: 10
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
await assert.rejects(
|
|
415
|
+
api.resources.products.get({
|
|
416
|
+
id: productWithoutReviews.id,
|
|
417
|
+
queryParams: {
|
|
418
|
+
include: ['reviews'],
|
|
419
|
+
fields: {
|
|
420
|
+
products: 'name',
|
|
421
|
+
reviews: 'definitelyHidden'
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}),
|
|
425
|
+
(error) => assertFieldsetError(error, {
|
|
426
|
+
field: 'definitelyHidden',
|
|
427
|
+
resourceType: 'reviews'
|
|
428
|
+
})
|
|
429
|
+
)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('should map fieldset errors to an HTTP 400 response', () => {
|
|
433
|
+
const error = new RestApiFieldsetError({
|
|
434
|
+
field: 'definitelyHidden',
|
|
435
|
+
resourceType: 'reviews'
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
assert.deepEqual(mapRestApiErrorToHttp(error), {
|
|
439
|
+
status: 400,
|
|
440
|
+
body: {
|
|
441
|
+
errors: [{
|
|
442
|
+
status: '400',
|
|
443
|
+
code: REST_API_FIELDSET_ERROR_CODE,
|
|
444
|
+
title: 'Invalid Sparse Fieldset',
|
|
445
|
+
detail: "Unknown sparse field 'definitelyHidden' requested for 'reviews'"
|
|
446
|
+
}]
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
})
|
|
383
450
|
})
|
|
384
451
|
|
|
385
452
|
describe('Async Computed Fields', () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Api } from 'hooked-api'
|
|
2
2
|
import {
|
|
3
|
+
AutoFilterPlugin,
|
|
3
4
|
FileHandlingPlugin,
|
|
4
5
|
QueryProjectionsPlugin,
|
|
5
6
|
RestApiAnyapiKnexPlugin,
|
|
@@ -1733,21 +1734,38 @@ export async function createRowPolicyApi (knex, pluginOptions = {}) {
|
|
|
1733
1734
|
return false
|
|
1734
1735
|
}
|
|
1735
1736
|
|
|
1736
|
-
query.
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1737
|
+
query.where(function visibilityGrants () {
|
|
1738
|
+
this.whereRaw('1 = 0')
|
|
1739
|
+
this.orWhere(function groupVisibilityGrant () {
|
|
1740
|
+
this.whereIn(
|
|
1741
|
+
column('access_group'),
|
|
1742
|
+
groups.map((group) => value('access_group', group))
|
|
1743
|
+
)
|
|
1744
|
+
})
|
|
1745
|
+
})
|
|
1740
1746
|
return true
|
|
1741
1747
|
},
|
|
1742
1748
|
...(rowPolicyOptions.policies || {})
|
|
1743
1749
|
}
|
|
1744
1750
|
})
|
|
1745
1751
|
|
|
1752
|
+
await api.use(AutoFilterPlugin, {
|
|
1753
|
+
resolvers: {
|
|
1754
|
+
workspace: ({ context }) => context.scopeValues?.workspaceId
|
|
1755
|
+
},
|
|
1756
|
+
presets: {
|
|
1757
|
+
workspace: {
|
|
1758
|
+
filters: [{ field: 'workspace_id', resolver: 'workspace' }]
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1746
1763
|
await api.addResource('policy_projects', {
|
|
1747
1764
|
schema: {
|
|
1748
1765
|
id: { type: 'id' },
|
|
1749
1766
|
name: { type: 'string', required: true, max: 200 },
|
|
1750
|
-
access_group: { type: 'string', required: true }
|
|
1767
|
+
access_group: { type: 'string', required: true },
|
|
1768
|
+
workspace_id: { type: 'string', required: true }
|
|
1751
1769
|
},
|
|
1752
1770
|
relationships: {
|
|
1753
1771
|
tasks: { type: 'hasMany', target: 'policy_tasks', foreignKey: 'project_id' },
|
|
@@ -1759,6 +1777,7 @@ export async function createRowPolicyApi (knex, pluginOptions = {}) {
|
|
|
1759
1777
|
otherKey: 'task_id'
|
|
1760
1778
|
}
|
|
1761
1779
|
},
|
|
1780
|
+
autofilter: 'workspace',
|
|
1762
1781
|
rowPolicy: 'groupVisibility',
|
|
1763
1782
|
tableName: 'row_policy_projects'
|
|
1764
1783
|
})
|
|
@@ -1770,6 +1789,7 @@ export async function createRowPolicyApi (knex, pluginOptions = {}) {
|
|
|
1770
1789
|
id: { type: 'id' },
|
|
1771
1790
|
title: { type: 'string', required: true, max: 200, search: true },
|
|
1772
1791
|
access_group: { type: 'string', required: true },
|
|
1792
|
+
workspace_id: { type: 'string', required: true },
|
|
1773
1793
|
project_id: {
|
|
1774
1794
|
type: 'number',
|
|
1775
1795
|
nullable: true,
|
|
@@ -1787,6 +1807,7 @@ export async function createRowPolicyApi (knex, pluginOptions = {}) {
|
|
|
1787
1807
|
otherKey: 'project_id'
|
|
1788
1808
|
}
|
|
1789
1809
|
},
|
|
1810
|
+
autofilter: 'workspace',
|
|
1790
1811
|
rowPolicy: 'groupVisibility',
|
|
1791
1812
|
tableName: 'row_policy_tasks'
|
|
1792
1813
|
})
|
package/tests/includes.test.js
CHANGED
|
@@ -2,6 +2,10 @@ import { describe, it, before, beforeEach, after } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import knexLib from 'knex'
|
|
4
4
|
import { createBasicApi, createExtendedApi } from './fixtures/api-configs.js'
|
|
5
|
+
import {
|
|
6
|
+
REST_API_FIELDSET_ERROR_CODE,
|
|
7
|
+
RestApiFieldsetError
|
|
8
|
+
} from '../index.js'
|
|
5
9
|
import {
|
|
6
10
|
validateJsonApiStructure,
|
|
7
11
|
resourceIdentifier,
|
|
@@ -165,6 +169,51 @@ describe('Include/Sideloading Operations', () => {
|
|
|
165
169
|
assert(includedTypes.includes('publishers'))
|
|
166
170
|
assert(includedTypes.includes('countries'))
|
|
167
171
|
})
|
|
172
|
+
|
|
173
|
+
it('should preserve typed fieldset errors across included relationships', async () => {
|
|
174
|
+
const requests = [
|
|
175
|
+
{
|
|
176
|
+
resource: basicApi.resources.countries,
|
|
177
|
+
id: testData.country.id,
|
|
178
|
+
include: 'books',
|
|
179
|
+
primaryType: 'countries',
|
|
180
|
+
includedType: 'books'
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
resource: basicApi.resources.books,
|
|
184
|
+
id: testData.book.id,
|
|
185
|
+
include: 'publisher',
|
|
186
|
+
primaryType: 'books',
|
|
187
|
+
includedType: 'publishers'
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
for (const request of requests) {
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
request.resource.get({
|
|
194
|
+
id: request.id,
|
|
195
|
+
queryParams: {
|
|
196
|
+
include: [request.include],
|
|
197
|
+
fields: {
|
|
198
|
+
[request.primaryType]: 'id',
|
|
199
|
+
[request.includedType]: 'definitelyHidden'
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
simplified: false
|
|
203
|
+
}),
|
|
204
|
+
(error) => {
|
|
205
|
+
assert(error instanceof RestApiFieldsetError)
|
|
206
|
+
assert.equal(error.code, REST_API_FIELDSET_ERROR_CODE)
|
|
207
|
+
assert.equal(error.statusCode, 400)
|
|
208
|
+
assert.deepEqual(error.details, {
|
|
209
|
+
field: 'definitelyHidden',
|
|
210
|
+
resourceType: request.includedType
|
|
211
|
+
})
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
168
217
|
})
|
|
169
218
|
|
|
170
219
|
describe('Nested Includes', () => {
|
package/tests/row-policy.test.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './helpers/test-utils.js'
|
|
12
12
|
import { storageMode } from './helpers/storage-mode.js'
|
|
13
13
|
import { createRowPolicyApi } from './fixtures/api-configs.js'
|
|
14
|
+
import { createStorageAdapterUtilities } from '../plugins/core/lib/querying/storage-adapter-utils.js'
|
|
14
15
|
|
|
15
16
|
const knex = knexLib({
|
|
16
17
|
client: 'better-sqlite3',
|
|
@@ -23,17 +24,23 @@ const knex = knexLib({
|
|
|
23
24
|
let api
|
|
24
25
|
const policyEvents = []
|
|
25
26
|
|
|
26
|
-
const adminContext = () => ({
|
|
27
|
-
|
|
27
|
+
const adminContext = (workspaceId = 'workspace-a') => ({
|
|
28
|
+
visibility: { all: true },
|
|
29
|
+
scopeValues: { workspaceId }
|
|
30
|
+
})
|
|
31
|
+
const groupContext = (...groups) => ({
|
|
32
|
+
visibility: { groups },
|
|
33
|
+
scopeValues: { workspaceId: 'workspace-a' }
|
|
34
|
+
})
|
|
28
35
|
|
|
29
|
-
const postProject = async (name, accessGroup) => {
|
|
36
|
+
const postProject = async (name, accessGroup, context = adminContext()) => {
|
|
30
37
|
return api.resources.policy_projects.post({
|
|
31
38
|
inputRecord: createJsonApiDocument('policy_projects', {
|
|
32
39
|
name,
|
|
33
40
|
access_group: accessGroup
|
|
34
41
|
}),
|
|
35
42
|
simplified: false
|
|
36
|
-
},
|
|
43
|
+
}, context)
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
const postTask = async ({ title, accessGroup, projectId, context = adminContext() }) => {
|
|
@@ -53,6 +60,39 @@ const postTask = async ({ title, accessGroup, projectId, context = adminContext(
|
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
describe('RowPolicy Plugin', () => {
|
|
63
|
+
it('keeps active query translation stable after the hook context is restored', () => {
|
|
64
|
+
const queryStorageAdapter = {
|
|
65
|
+
translateColumn: (field) => field === 'id' ? 'record_id' : field
|
|
66
|
+
}
|
|
67
|
+
const ambientStorageAdapter = {
|
|
68
|
+
translateColumn: (field) => field === 'id' ? 'wrong_id' : field
|
|
69
|
+
}
|
|
70
|
+
const context = {
|
|
71
|
+
knexQuery: {
|
|
72
|
+
scopeName: 'organisationUnits',
|
|
73
|
+
tableName: 'organisation_units',
|
|
74
|
+
storageAdapter: queryStorageAdapter
|
|
75
|
+
},
|
|
76
|
+
storageAdapter: ambientStorageAdapter
|
|
77
|
+
}
|
|
78
|
+
const utilities = createStorageAdapterUtilities({ context })
|
|
79
|
+
|
|
80
|
+
delete context.knexQuery
|
|
81
|
+
delete context.storageAdapter
|
|
82
|
+
|
|
83
|
+
assert.equal(
|
|
84
|
+
utilities.translateColumn('organisationUnits', 'id'),
|
|
85
|
+
'organisation_units.record_id'
|
|
86
|
+
)
|
|
87
|
+
assert.equal(
|
|
88
|
+
utilities.translateColumn('organisationUnits', 'id', 'visible_units'),
|
|
89
|
+
'visible_units.record_id'
|
|
90
|
+
)
|
|
91
|
+
assert.equal(utilities.defaultAliasForScope('users'), 'users')
|
|
92
|
+
assert.equal(Object.hasOwn(context, 'knexQuery'), false)
|
|
93
|
+
assert.equal(Object.hasOwn(context, 'storageAdapter'), false)
|
|
94
|
+
})
|
|
95
|
+
|
|
56
96
|
before(async () => {
|
|
57
97
|
api = await createRowPolicyApi(knex, {
|
|
58
98
|
onPolicy: (event) => policyEvents.push(event)
|
|
@@ -73,7 +113,7 @@ describe('RowPolicy Plugin', () => {
|
|
|
73
113
|
])
|
|
74
114
|
})
|
|
75
115
|
|
|
76
|
-
it('applies visibility before offset pagination and counts the same dataset', async () => {
|
|
116
|
+
it('applies deferred grouped visibility before offset pagination and counts the same dataset', async () => {
|
|
77
117
|
await postProject('Allowed 1', 'group-a')
|
|
78
118
|
await postProject('Hidden 1', 'group-b')
|
|
79
119
|
await postProject('Allowed 2', 'group-a')
|
|
@@ -112,6 +152,22 @@ describe('RowPolicy Plugin', () => {
|
|
|
112
152
|
)
|
|
113
153
|
})
|
|
114
154
|
|
|
155
|
+
it('combines row policy visibility with the resource autofilter', async () => {
|
|
156
|
+
await postProject('Visible', 'group-a')
|
|
157
|
+
await postProject('Wrong group', 'group-b')
|
|
158
|
+
await postProject('Wrong workspace', 'group-a', adminContext('workspace-b'))
|
|
159
|
+
|
|
160
|
+
const result = await api.resources.policy_projects.query({
|
|
161
|
+
queryParams: { sort: ['id'] },
|
|
162
|
+
simplified: false
|
|
163
|
+
}, groupContext('group-a'))
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(
|
|
166
|
+
result.data.map((record) => record.attributes.name),
|
|
167
|
+
['Visible']
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
115
171
|
it('keeps cursor pagination inside the visible dataset', async () => {
|
|
116
172
|
await postProject('Allowed 1', 'group-a')
|
|
117
173
|
await postProject('Hidden 1', 'group-b')
|
|
@@ -404,7 +460,7 @@ describe('RowPolicy Plugin', () => {
|
|
|
404
460
|
const result = await api.resources.policy_projects.query({
|
|
405
461
|
queryParams: { page: { number: 1, size: 10 } },
|
|
406
462
|
simplified: false
|
|
407
|
-
})
|
|
463
|
+
}, { scopeValues: { workspaceId: 'workspace-a' } })
|
|
408
464
|
|
|
409
465
|
assert.deepEqual(result.data, [])
|
|
410
466
|
assert.equal(result.meta.pagination.total, 0)
|