json-rest-api 1.0.24 → 1.0.26

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.
Files changed (84) hide show
  1. package/README.md +2 -1
  2. package/docs/API.md +12 -9
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Row_Policies.md +605 -0
  10. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  11. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  12. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  13. package/docs/GUIDE/index.md +4 -1
  14. package/fixing_plan.md +112 -0
  15. package/index.js +2 -0
  16. package/package.json +1 -1
  17. package/plugins/core/bulk-operations-plugin.js +5 -4
  18. package/plugins/core/connectors/express-plugin.js +6 -1
  19. package/plugins/core/connectors/fastify-plugin.js +5 -1
  20. package/plugins/core/connectors/lib/connector-core.js +17 -11
  21. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  22. package/plugins/core/file-handling-plugin.js +126 -96
  23. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  24. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +36 -26
  25. package/plugins/core/lib/anyapi/schema-utils.js +1 -0
  26. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  27. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  28. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  29. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  30. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  31. package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
  32. package/plugins/core/lib/querying/url-helpers.js +9 -21
  33. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  34. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  35. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  36. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  37. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  38. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  39. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  40. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  41. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  42. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  43. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  44. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  45. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  46. package/plugins/core/rest-api-knex-plugin.js +70 -60
  47. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  48. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  49. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  50. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  51. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  52. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  53. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  54. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  55. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  56. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  57. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  58. package/plugins/core/rest-api-plugin.js +6 -36
  59. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  60. package/plugins/storage/local-storage.js +44 -19
  61. package/plugins/storage/s3-storage.js +21 -55
  62. package/tests/README.md +1 -1
  63. package/tests/anyapi-cursor-pagination.test.js +7 -1
  64. package/tests/anyapi-custom-idproperty.test.js +163 -0
  65. package/tests/fastify-plugin.test.js +28 -0
  66. package/tests/field-setters.test.js +10 -10
  67. package/tests/file-handling.test.js +118 -0
  68. package/tests/fixtures/api-configs.js +383 -4
  69. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  70. package/tests/http-connectors-parity.test.js +165 -5
  71. package/tests/local-storage.test.js +96 -0
  72. package/tests/pagination-cursor-multifield.test.js +121 -0
  73. package/tests/pagination-enhanced.test.js +66 -15
  74. package/tests/post-commit-hooks.test.js +120 -0
  75. package/tests/public-exports.test.js +15 -0
  76. package/tests/put-create-parity.test.js +216 -0
  77. package/tests/relationship-endpoints.test.js +259 -0
  78. package/tests/return-record-settings.test.js +184 -0
  79. package/tests/row-policy.test.js +489 -0
  80. package/tests/s3-storage.test.js +38 -0
  81. package/tests/searchschema-merge.test.js +17 -0
  82. package/tests/socketio.test.js +62 -0
  83. package/tests/storage-mapping.test.js +1 -0
  84. package/tests/test-development-guide.md +5 -5
@@ -1,4 +1,8 @@
1
- import { handleWriteMethodError } from './common.js'
1
+ import {
2
+ commitOwnedTransaction,
3
+ handleWriteMethodError,
4
+ validateRelationshipRoutePayload
5
+ } from './common.js'
2
6
  import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
3
7
 
4
8
  /**
@@ -19,6 +23,7 @@ export default async function patchRelationshipMethod ({ params, context, vars,
19
23
  scopeName
20
24
  })
21
25
  context.relationshipName = params.relationshipName
26
+ context.schemaInfo = scopes[scopeName].vars.schemaInfo
22
27
 
23
28
  // Transaction handling
24
29
  context.transaction = params.transaction ||
@@ -31,6 +36,14 @@ export default async function patchRelationshipMethod ({ params, context, vars,
31
36
  await runHooks('checkPermissions')
32
37
  await runHooks('checkPermissionsPatchRelationship')
33
38
 
39
+ const relationshipData = validateRelationshipRoutePayload({
40
+ context,
41
+ vars,
42
+ scopeName,
43
+ operation: 'patchRelationship',
44
+ relationshipData: params.relationshipData
45
+ })
46
+
34
47
  // Reuse existing patch with relationship data
35
48
  await scope.patch({
36
49
  id: context.id,
@@ -39,21 +52,19 @@ export default async function patchRelationshipMethod ({ params, context, vars,
39
52
  type: scopeName,
40
53
  id: context.id,
41
54
  relationships: {
42
- [params.relationshipName]: { data: params.relationshipData }
55
+ [params.relationshipName]: { data: relationshipData }
43
56
  }
44
57
  }
45
58
  },
46
59
  transaction: context.transaction,
47
60
  simplified: false,
48
61
  isTransport: params.isTransport
49
- })
62
+ }, { ...context })
50
63
 
51
64
  await runHooks('finish')
52
65
  await runHooks('finishPatchRelationship')
53
66
 
54
- if (context.shouldCommit) {
55
- await context.transaction.commit()
56
- }
67
+ await commitOwnedTransaction(context, runHooks)
57
68
 
58
69
  // 204 No Content
59
70
  } catch (error) {
@@ -14,7 +14,8 @@ import {
14
14
  applyFieldSetters,
15
15
  validatePivotResource,
16
16
  handleRecordReturnAfterWrite,
17
- handleWriteMethodError
17
+ handleWriteMethodError,
18
+ commitOwnedTransaction
18
19
  } from './common.js'
19
20
 
20
21
  /**
@@ -222,11 +223,7 @@ export default async function patchMethod ({
222
223
  log
223
224
  })
224
225
 
225
- // Commit transaction if we created it
226
- if (context.shouldCommit) {
227
- await context.transaction.commit()
228
- await runHooks('afterCommit')
229
- }
226
+ await commitOwnedTransaction(context, runHooks)
230
227
 
231
228
  return ret
232
229
  } catch (error) {
@@ -1,5 +1,11 @@
1
- import { RestApiResourceError, RestApiValidationError, RestApiPayloadError } from '../../../lib/rest-api-errors.js'
2
- import { findRelationshipDefinition, handleWriteMethodError } from './common.js'
1
+ import { RestApiResourceError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
2
+ import {
3
+ commitOwnedTransaction,
4
+ findRelationshipDefinition,
5
+ getVisibleRelationshipParent,
6
+ handleWriteMethodError,
7
+ validateRelationshipRoutePayload
8
+ } from './common.js'
3
9
  import { createPivotRecords } from '../lib/writing/many-to-many-manipulations.js'
4
10
  import {
5
11
  normalizeRelationshipIdentifiers,
@@ -50,22 +56,28 @@ export default async function postRelationshipMethod ({ params, context, vars, h
50
56
  )
51
57
  }
52
58
 
53
- if (!Array.isArray(params.relationshipData)) {
54
- throw new RestApiPayloadError('POST to relationship requires array of resource identifiers')
55
- }
59
+ params.relationshipData = validateRelationshipRoutePayload({
60
+ context,
61
+ vars,
62
+ scopeName,
63
+ operation: 'postRelationship',
64
+ relationshipData: params.relationshipData
65
+ })
56
66
  params.relationshipData = normalizeRelationshipIdentifiers(params.relationshipData, { api })
57
67
 
58
68
  // Check permissions
59
69
  await runHooks('checkPermissions')
60
70
  await runHooks('checkPermissionsPostRelationship')
61
71
 
62
- // Verify parent exists
63
- const exists = await helpers.dataExists({
72
+ // Verify the parent exists in the caller's visible dataset.
73
+ const parentRecord = await getVisibleRelationshipParent({
74
+ context,
75
+ helpers,
64
76
  scopeName,
65
- context: { db: context.db, id: context.id, schemaInfo: context.schemaInfo },
77
+ runHooks
66
78
  })
67
79
 
68
- if (!exists) {
80
+ if (!parentRecord) {
69
81
  throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
70
82
  }
71
83
 
@@ -84,7 +96,7 @@ export default async function postRelationshipMethod ({ params, context, vars, h
84
96
  through: relDef.through,
85
97
  foreignKey: relDef.foreignKey,
86
98
  otherKey: relDef.otherKey
87
- }, params.relationshipData, context.transaction)
99
+ }, params.relationshipData, context.transaction, context)
88
100
  }
89
101
  } else if (relDef.type === 'hasMany') {
90
102
  // Update foreign keys for hasMany
@@ -101,16 +113,14 @@ export default async function postRelationshipMethod ({ params, context, vars, h
101
113
  },
102
114
  transaction: context.transaction,
103
115
  simplified: false
104
- })
116
+ }, { ...context })
105
117
  }
106
118
  }
107
119
 
108
120
  await runHooks('finish')
109
121
  await runHooks('finishPostRelationship')
110
122
 
111
- if (context.shouldCommit) {
112
- await context.transaction.commit()
113
- }
123
+ await commitOwnedTransaction(context, runHooks)
114
124
 
115
125
  // 204 No Content
116
126
  } catch (error) {
@@ -9,7 +9,8 @@ import {
9
9
  applyFieldSetters,
10
10
  validatePivotResource,
11
11
  handleRecordReturnAfterWrite,
12
- handleWriteMethodError
12
+ handleWriteMethodError,
13
+ commitOwnedTransaction
13
14
  } from './common.js'
14
15
 
15
16
  /**
@@ -151,7 +152,7 @@ export default async function postMethod ({
151
152
 
152
153
  // Validate pivot resource exists
153
154
  validatePivotResource(scopes, relDef, relName)
154
- await createPivotRecords(api, context.id, relDef, relData, context.transaction)
155
+ await createPivotRecords(api, context.id, relDef, relData, context.transaction, context)
155
156
  }
156
157
 
157
158
  const ret = await handleRecordReturnAfterWrite({
@@ -168,11 +169,7 @@ export default async function postMethod ({
168
169
  log
169
170
  })
170
171
 
171
- // Commit transaction if we created it
172
- if (context.shouldCommit) {
173
- await context.transaction.commit()
174
- await runHooks('afterCommit')
175
- }
172
+ await commitOwnedTransaction(context, runHooks)
176
173
 
177
174
  return ret
178
175
  } catch (error) {
@@ -15,7 +15,8 @@ import {
15
15
  applyFieldSetters,
16
16
  validatePivotResource,
17
17
  handleRecordReturnAfterWrite,
18
- handleWriteMethodError
18
+ handleWriteMethodError,
19
+ commitOwnedTransaction
19
20
  } from './common.js'
20
21
 
21
22
  /**
@@ -274,7 +275,7 @@ export default async function putMethod ({
274
275
  }
275
276
  })
276
277
  } else if (relData.length > 0) {
277
- await createPivotRecords(api, context.id, relDef, relData, context.transaction)
278
+ await createPivotRecords(api, context.id, relDef, relData, context.transaction, context)
278
279
  }
279
280
  }
280
281
 
@@ -292,11 +293,7 @@ export default async function putMethod ({
292
293
  log
293
294
  })
294
295
 
295
- // Commit transaction if we created it
296
- if (context.shouldCommit) {
297
- await context.transaction.commit()
298
- await runHooks('afterCommit')
299
- }
296
+ await commitOwnedTransaction(context, runHooks)
300
297
 
301
298
  return ret
302
299
  } catch (error) {
@@ -21,6 +21,7 @@ import addRouteMethod from './rest-api-plugin-methods/add-route.js'
21
21
  import releaseMethod from './rest-api-plugin-methods/release.js'
22
22
  import { defaultDataHelpers } from './lib/querying-writing/default-data-helpers.js'
23
23
  import { DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT, DEFAULT_INCLUDE_DEPTH_LIMIT } from './lib/querying-writing/knex-constants.js'
24
+ import { normalizeReturnRecordSetting } from './lib/querying-writing/return-record-settings.js'
24
25
 
25
26
  import getRelatedMethod from './rest-api-plugin-methods/get-related.js'
26
27
  import postRelationshipMethod from './rest-api-plugin-methods/post-relationship.js'
@@ -71,42 +72,11 @@ export const RestApiPlugin = {
71
72
  ? restApiOptions.normalizeId
72
73
  : defaultNormalizeResourceId
73
74
 
74
- // Return full record configuration for API and Transport
75
- // Support values: 'no', 'minimal', 'full'
76
- const normalizeReturnValue = (value, defaultValue) => {
77
- if (['no', 'minimal', 'full'].includes(value)) return value
78
- return defaultValue
79
- }
80
-
81
- const optionConfigs = [
82
- {
83
- propName: 'returnRecordApi',
84
- defaultValue: 'full',
85
- },
86
- {
87
- propName: 'returnRecordTransport',
88
- defaultValue: 'no',
89
- },
90
- ]
91
-
92
- for (const config of optionConfigs) {
93
- const optionValue = restApiOptions[config.propName]
94
- let processedValue
95
-
96
- if (typeof optionValue === 'object' && optionValue !== null) {
97
- processedValue = {
98
- post: normalizeReturnValue(optionValue.post, config.defaultValue),
99
- put: normalizeReturnValue(optionValue.put, config.defaultValue),
100
- patch: normalizeReturnValue(optionValue.patch, config.defaultValue),
101
- }
102
- } else if (optionValue !== undefined) {
103
- const normalized = normalizeReturnValue(optionValue, config.defaultValue)
104
- processedValue = { post: normalized, put: normalized, patch: normalized }
105
- } else {
106
- processedValue = { post: config.defaultValue, put: config.defaultValue, patch: config.defaultValue }
107
- }
108
- vars[config.propName] = processedValue
109
- }
75
+ // Return record configuration for API and Transport.
76
+ // Preferred values are 'no', 'minimal', and 'full'; booleans are normalized
77
+ // for backwards compatibility.
78
+ vars.returnRecordApi = normalizeReturnRecordSetting(restApiOptions.returnRecordApi, 'full')
79
+ vars.returnRecordTransport = normalizeReturnRecordSetting(restApiOptions.returnRecordTransport, 'no')
110
80
 
111
81
  log.debug('returnRecordApi configuration:', vars.returnRecordApi)
112
82
  log.debug('returnRecordTransport configuration:', vars.returnRecordTransport)
@@ -0,0 +1,157 @@
1
+ import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
2
+
3
+ const INLINE_POLICY_NAME = '<inline>'
4
+
5
+ function createRowPolicyError (message, code = 'REST_API_ROW_POLICY_CONTRACT') {
6
+ const error = new Error(message)
7
+ error.code = code
8
+ return error
9
+ }
10
+
11
+ function compileRowPolicy ({ scopeName, scopeOptions = {}, policies }) {
12
+ const definition = scopeOptions.rowPolicy
13
+
14
+ if (definition === undefined || definition === null || definition === false) {
15
+ return null
16
+ }
17
+
18
+ if (typeof definition === 'function') {
19
+ return {
20
+ name: INLINE_POLICY_NAME,
21
+ source: 'inline',
22
+ applyQuery: definition
23
+ }
24
+ }
25
+
26
+ if (typeof definition === 'string') {
27
+ const name = definition.trim()
28
+ const applyQuery = policies[name]
29
+
30
+ if (!name || !applyQuery) {
31
+ throw createRowPolicyError(
32
+ `Unknown row policy '${definition}' on resource '${scopeName}'.`
33
+ )
34
+ }
35
+
36
+ return {
37
+ name,
38
+ source: 'registry',
39
+ applyQuery
40
+ }
41
+ }
42
+
43
+ throw createRowPolicyError(
44
+ `Row policy for resource '${scopeName}' must be a registered policy name, a function, or false.`
45
+ )
46
+ }
47
+
48
+ export const RowPolicyPlugin = {
49
+ name: 'row-policy',
50
+ dependencies: ['rest-api', 'rest-api-knex|rest-api-anyapi-knex'],
51
+
52
+ install ({ api, addHook, vars, helpers, log, scopes, pluginOptions = {} }) {
53
+ if (!api.knex?.instance) {
54
+ throw new Error('RowPolicyPlugin requires a storage plugin with knex support (rest-api-knex or rest-api-anyapi-knex).')
55
+ }
56
+
57
+ const policies = { ...(pluginOptions.policies || {}) }
58
+ for (const [name, policy] of Object.entries(policies)) {
59
+ if (typeof policy !== 'function') {
60
+ throw createRowPolicyError(`Row policy '${name}' must be a function.`)
61
+ }
62
+ }
63
+
64
+ vars.rowPolicy = {
65
+ policies: Object.keys(policies)
66
+ }
67
+
68
+ addHook('scope:added', 'compile-row-policy', {}, ({ context }) => {
69
+ const { scopeName, scopeOptions = {} } = context
70
+ const scope = scopes[scopeName]
71
+
72
+ scope.vars.rowPolicy = compileRowPolicy({
73
+ scopeName,
74
+ scopeOptions,
75
+ policies
76
+ })
77
+ })
78
+
79
+ addHook('knexQueryFiltering', 'apply-row-policy', {}, async (hookParams) => {
80
+ const { context } = hookParams
81
+ const {
82
+ query,
83
+ scopeName,
84
+ tableName,
85
+ db,
86
+ queryPurpose = 'unspecified',
87
+ isAnyApi = false,
88
+ storageAdapter: hookStorageAdapter
89
+ } = context.knexQuery || {}
90
+
91
+ if (!query || !scopeName) return
92
+
93
+ const compiledPolicy = scopes[scopeName]?.vars?.rowPolicy
94
+ if (!compiledPolicy) return
95
+
96
+ const adapterUtils = createStorageAdapterUtilities(hookParams, {
97
+ getStorageAdapter: helpers.getStorageAdapter
98
+ })
99
+ const storageAdapter = hookStorageAdapter || adapterUtils.fetchStorageAdapter(scopeName)
100
+
101
+ const column = (field, options = {}) => {
102
+ const targetScopeName = options.scopeName || scopeName
103
+ const alias = Object.prototype.hasOwnProperty.call(options, 'alias')
104
+ ? options.alias
105
+ : adapterUtils.defaultAliasForScope(targetScopeName)
106
+ return adapterUtils.translateColumn(targetScopeName, field, alias)
107
+ }
108
+
109
+ const value = (field, rawValue, options = {}) => {
110
+ const targetScopeName = options.scopeName || scopeName
111
+ return adapterUtils.translateFilterValue(targetScopeName, field, rawValue)
112
+ }
113
+
114
+ const result = await compiledPolicy.applyQuery({
115
+ query,
116
+ context,
117
+ scopeName,
118
+ tableName,
119
+ queryPurpose,
120
+ db,
121
+ isAnyApi,
122
+ storageAdapter,
123
+ column,
124
+ value,
125
+ api
126
+ })
127
+
128
+ if (result === false) {
129
+ query.whereRaw('1 = 0')
130
+ } else if (result !== true) {
131
+ throw createRowPolicyError(
132
+ `Row policy '${compiledPolicy.name}' on resource '${scopeName}' must return true after applying its predicate, or false to deny all rows.`
133
+ )
134
+ }
135
+ })
136
+
137
+ api.rowPolicies = {
138
+ getConfig: () => ({
139
+ policies: Object.keys(policies)
140
+ }),
141
+
142
+ getScopeConfig: (scopeName) => {
143
+ const compiledPolicy = scopes[scopeName]?.vars?.rowPolicy
144
+ if (!compiledPolicy) return null
145
+
146
+ return {
147
+ policy: compiledPolicy.name,
148
+ source: compiledPolicy.source
149
+ }
150
+ }
151
+ }
152
+
153
+ log.info('Row policy plugin installed', {
154
+ policies: Object.keys(policies)
155
+ })
156
+ }
157
+ }
@@ -19,6 +19,45 @@ export class LocalStorage {
19
19
  this.maxFilenameLength = options.maxFilenameLength || 255
20
20
  }
21
21
 
22
+ hasPathSegments (filename) {
23
+ return filename.includes('/') ||
24
+ filename.includes('\\') ||
25
+ path.isAbsolute(filename) ||
26
+ path.win32.isAbsolute(filename) ||
27
+ /^[A-Za-z]:/.test(filename)
28
+ }
29
+
30
+ validateCustomBasename (basename) {
31
+ if (typeof basename !== 'string') {
32
+ throw new Error('Custom nameGenerator must return a string')
33
+ }
34
+ if (basename.trim().length === 0) {
35
+ throw new Error('Custom nameGenerator must return a non-empty basename')
36
+ }
37
+ if (this.hasPathSegments(basename)) {
38
+ throw new Error('Custom nameGenerator must return a basename, not a path')
39
+ }
40
+ return basename
41
+ }
42
+
43
+ resolveStoragePath (filename) {
44
+ if (typeof filename !== 'string' || filename.trim().length === 0) {
45
+ throw new Error('Invalid file name')
46
+ }
47
+ if (this.hasPathSegments(filename)) {
48
+ throw new Error('Invalid file path')
49
+ }
50
+
51
+ const resolvedDir = path.resolve(this.directory)
52
+ const resolvedPath = path.resolve(resolvedDir, filename)
53
+ const relative = path.relative(resolvedDir, resolvedPath)
54
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
55
+ throw new Error('Invalid file path')
56
+ }
57
+
58
+ return resolvedPath
59
+ }
60
+
22
61
  /**
23
62
  * Generate a safe filename
24
63
  */
@@ -61,7 +100,7 @@ export class LocalStorage {
61
100
  if (!this.nameGenerator) {
62
101
  throw new Error('nameGenerator function required for custom strategy')
63
102
  }
64
- basename = await this.nameGenerator(file)
103
+ basename = this.validateCustomBasename(await this.nameGenerator(file))
65
104
  break
66
105
 
67
106
  default:
@@ -107,7 +146,7 @@ export class LocalStorage {
107
146
  * Ensure filename is unique
108
147
  */
109
148
  async ensureUnique (filename) {
110
- const filepath = path.join(this.directory, filename)
149
+ const filepath = this.resolveStoragePath(filename)
111
150
 
112
151
  try {
113
152
  await fs.access(filepath)
@@ -120,7 +159,7 @@ export class LocalStorage {
120
159
  do {
121
160
  newFilename = `${base}_${counter}${ext}`
122
161
  counter++
123
- } while (await this.fileExists(path.join(this.directory, newFilename)))
162
+ } while (await this.fileExists(this.resolveStoragePath(newFilename)))
124
163
 
125
164
  return newFilename
126
165
  } catch (error) {
@@ -171,14 +210,7 @@ export class LocalStorage {
171
210
 
172
211
  // Generate secure filename
173
212
  const filename = await this.generateFilename(file)
174
- const filepath = path.join(this.directory, filename)
175
-
176
- // Ensure we're not writing outside our directory (defense in depth)
177
- const resolvedPath = path.resolve(filepath)
178
- const resolvedDir = path.resolve(this.directory)
179
- if (!resolvedPath.startsWith(resolvedDir)) {
180
- throw new Error('Invalid file path')
181
- }
213
+ const filepath = this.resolveStoragePath(filename)
182
214
 
183
215
  // Write file
184
216
  if (file.data) {
@@ -198,14 +230,7 @@ export class LocalStorage {
198
230
  */
199
231
  async delete (url) {
200
232
  const filename = path.basename(url)
201
- const filepath = path.join(this.directory, filename)
202
-
203
- // Security check
204
- const resolvedPath = path.resolve(filepath)
205
- const resolvedDir = path.resolve(this.directory)
206
- if (!resolvedPath.startsWith(resolvedDir)) {
207
- throw new Error('Invalid file path')
208
- }
233
+ const filepath = this.resolveStoragePath(filename)
209
234
 
210
235
  try {
211
236
  await fs.unlink(filepath)
@@ -1,18 +1,17 @@
1
1
  /**
2
- * S3 Storage Adapter
2
+ * S3 Storage Adapter (mock/demo)
3
3
  *
4
- * Stores uploaded files in Amazon S3 or S3-compatible storage.
4
+ * Generates S3-style URLs without uploading files to Amazon S3.
5
5
  *
6
6
  * Usage:
7
7
  * ```javascript
8
- * import { S3Storage } from 'jsonrestapi/lib/storage/s3-storage.js';
8
+ * import { S3Storage } from 'json-rest-api';
9
9
  *
10
10
  * const storage = new S3Storage({
11
11
  * bucket: 'my-uploads',
12
12
  * region: 'us-east-1',
13
- * accessKeyId: process.env.AWS_ACCESS_KEY_ID,
14
- * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
15
- * prefix: 'uploads/' // optional path prefix
13
+ * prefix: 'uploads/',
14
+ * mockMode: true
16
15
  * });
17
16
  *
18
17
  * const schema = {
@@ -24,6 +23,12 @@
24
23
  import crypto from 'crypto'
25
24
  import path from 'path'
26
25
 
26
+ const REAL_S3_NOT_IMPLEMENTED_MESSAGE = 'S3Storage real S3 mode is not implemented. The included adapter only supports mockMode: true; provide a production storage adapter or add an AWS SDK-backed implementation.'
27
+
28
+ function createRealS3NotImplementedError () {
29
+ return new Error(REAL_S3_NOT_IMPLEMENTED_MESSAGE)
30
+ }
31
+
27
32
  export class S3Storage {
28
33
  constructor (options = {}) {
29
34
  this.bucket = options.bucket
@@ -35,19 +40,15 @@ export class S3Storage {
35
40
  throw new Error('S3Storage requires a bucket name')
36
41
  }
37
42
 
38
- // Note: In a real implementation, you would initialize the AWS SDK here
39
- // For this example, we'll just mock the behavior
40
43
  this.mockMode = options.mockMode !== false // Default to mock mode
41
44
 
42
45
  if (!this.mockMode) {
43
- // Real S3 initialization would go here
44
- // const { S3Client } = require('@aws-sdk/client-s3');
45
- // this.s3 = new S3Client({ region: this.region });
46
+ throw createRealS3NotImplementedError()
46
47
  }
47
48
  }
48
49
 
49
50
  /**
50
- * Upload a file to S3
51
+ * Upload a file to mock S3 storage.
51
52
  * @param {Object} file - File object from detector
52
53
  * @returns {Promise<string>} URL of the uploaded file
53
54
  */
@@ -67,61 +68,26 @@ export class S3Storage {
67
68
  return url
68
69
  }
69
70
 
70
- // Real S3 upload would go here
71
- /*
72
- const { PutObjectCommand } = require('@aws-sdk/client-s3');
73
-
74
- const command = new PutObjectCommand({
75
- Bucket: this.bucket,
76
- Key: key,
77
- Body: file.data,
78
- ContentType: file.mimetype,
79
- ACL: this.acl,
80
- Metadata: {
81
- originalName: file.filename
82
- }
83
- });
84
-
85
- await this.s3.send(command);
86
-
87
- // Return public URL
88
- if (this.acl === 'public-read') {
89
- return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
90
- } else {
91
- // For private files, you might return a signed URL
92
- return await this.getSignedUrl(key);
93
- }
94
- */
71
+ throw createRealS3NotImplementedError()
95
72
  }
96
73
 
97
74
  /**
98
- * Delete a file from S3
75
+ * Delete a file from mock S3 storage.
99
76
  * @param {string} url - URL returned by upload()
100
77
  * @returns {Promise<void>}
101
78
  */
102
79
  async delete (url) {
103
- const urlObj = new URL(url)
104
-
105
- if (this.mockMode) {
106
- // Mock mode - just simulate deletion
107
- await new Promise(resolve => setTimeout(resolve, 50))
108
- return
80
+ if (!this.mockMode) {
81
+ throw createRealS3NotImplementedError()
109
82
  }
110
83
 
84
+ const urlObj = new URL(url)
85
+
111
86
  if (urlObj.pathname.length === 0) {
112
87
  throw new Error('Invalid S3 object URL')
113
88
  }
114
89
 
115
- // Real S3 delete would go here
116
- /*
117
- const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
118
-
119
- const command = new DeleteObjectCommand({
120
- Bucket: this.bucket,
121
- Key: key
122
- });
123
-
124
- await this.s3.send(command);
125
- */
90
+ // Mock mode - just simulate deletion
91
+ await new Promise(resolve => setTimeout(resolve, 50))
126
92
  }
127
93
  }
package/tests/README.md CHANGED
@@ -20,7 +20,7 @@ All tests MUST use `simplified: false` to ensure JSON:API compliance:
20
20
  const result = await api.resources.resourceName.method({
21
21
  inputRecord: jsonApiDocument,
22
22
  simplified: false,
23
- returnFullRecord: true/false // Based on needs
23
+ returnFullRecord: 'full' // Use 'no', 'minimal', or 'full' based on needs
24
24
  });
25
25
  ```
26
26