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 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
@@ -33,6 +33,7 @@ export { S3Storage } from './plugins/storage/s3-storage.js'
33
33
 
34
34
  // Error classes for consumers who need them
35
35
  export {
36
+ RestApiFieldsetError,
36
37
  RestApiValidationError,
37
38
  RestApiResourceError,
38
39
  RestApiPayloadError
@@ -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
+ }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
- #translateWhereArgs (method, args) {
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
- return [maybeWrapCallback(this, first), ...rest]
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] = maybeWrapCallback(this, rest[rest.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, maybeWrapCallback(this, values), ...rest]
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 [maybeWrapCallback(this, first), ...rest]
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 maybeWrapCallback(this, value)
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, maybeWrapCallback(this, values), ...rest]
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(prop, args)
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(prop, args)
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
- const enhancedError = new Error(
161
- `Failed to process includes${context?.scopeName ? ` for scope '${context.scopeName}'` : ''}: ${error.message}`
162
- )
163
- enhancedError.originalError = error
164
- throw enhancedError
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
- // Log error with context and re-throw
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 new Error(`Failed to load relationship metadata for scope '${scopeName}': ${error.message}`)
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
- // Re-throw with enhanced error message
640
- const enhancedError = new Error(
641
- `Failed to load belongsTo relationship '${includeName}' for field '${fieldName}': ${error.message}`
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
- // Re-throw with enhanced error message
1173
- const enhancedError = new Error(
1174
- `Failed to load hasMany relationship '${includeName}' for scope '${scopeName}': ${error.message}`
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
- // Re-throw with enhanced error message
1552
- const enhancedError = new Error(
1553
- `Failed to load polymorphic belongsTo relationship '${relName}': ${error.message}`
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
- // Re-throw with enhanced error message
1774
- const enhancedError = new Error(
1775
- `Failed to load reverse polymorphic relationship '${includeName}' via '${relDef?.via}' for scope '${scopeName}': ${error.message}`
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
- // Re-throw with additional context
2032
- const enhancedError = new Error(`Failed to build included resources for scope '${scopeName}': ${error.message}`)
2033
- enhancedError.originalError = error
2034
- enhancedError.context = {
2035
- scopeName,
2036
- includeParam,
2037
- recordCount: records?.length || 0
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 === context?.knexQuery?.scopeName) {
11
- adapter = context.storageAdapter ||
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 === context?.knexQuery?.scopeName) {
27
- return context.knexQuery?.tableName || scopeName
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) throw new Error(`Unknown sparse field '${field}' requested for '${scopeName}'`)
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
- /Unknown sparse field 'unknown_field'/
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
- /Unknown sparse field 'reviews_ids'/
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.whereIn(
1737
- column('access_group'),
1738
- groups.map((group) => value('access_group', group))
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
  })
@@ -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', () => {
@@ -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 = () => ({ visibility: { all: true } })
27
- const groupContext = (...groups) => ({ visibility: { groups } })
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
- }, adminContext())
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)