json-rest-api 1.0.16 → 1.0.18
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/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export { RestApiPlugin } from './plugins/core/rest-api-plugin.js'
|
|
|
13
13
|
export { FileHandlingPlugin } from './plugins/core/file-handling-plugin.js'
|
|
14
14
|
export { CorsPlugin } from './plugins/core/rest-api-cors-plugin.js'
|
|
15
15
|
export { AccessPlugin } from './plugins/core/rest-api-access.js'
|
|
16
|
+
export { LabelPlugin } from './plugins/core/rest-api-label-plugin.js'
|
|
16
17
|
|
|
17
18
|
// Database plugins
|
|
18
19
|
export { RestApiKnexPlugin } from './plugins/core/rest-api-knex-plugin.js'
|
package/package.json
CHANGED
|
@@ -24,6 +24,13 @@ export const AccessPlugin = {
|
|
|
24
24
|
|
|
25
25
|
const ownershipField = () => config.ownership.field
|
|
26
26
|
|
|
27
|
+
// Minimal helper: treat ownerField false/null as ownership disabled (alias)
|
|
28
|
+
const isOwnershipDisabled = (scopeOptions = {}) => (
|
|
29
|
+
scopeOptions?.ownership === false ||
|
|
30
|
+
scopeOptions?.ownerField === false ||
|
|
31
|
+
scopeOptions?.ownerField === null
|
|
32
|
+
)
|
|
33
|
+
|
|
27
34
|
const evaluateOwnership = ({ record, field, schemaInfo, userId }) => {
|
|
28
35
|
if (!record) return 'unknown'
|
|
29
36
|
|
|
@@ -161,7 +168,7 @@ export const AccessPlugin = {
|
|
|
161
168
|
if (!config.ownership.enabled) return
|
|
162
169
|
const { fields, scopeName, scopeOptions = {} } = context
|
|
163
170
|
if (config.ownership.excludeResources.includes(scopeName)) return
|
|
164
|
-
if (scopeOptions
|
|
171
|
+
if (isOwnershipDisabled(scopeOptions)) return
|
|
165
172
|
|
|
166
173
|
const field = ownershipField()
|
|
167
174
|
const relationshipName = fields[field]?.as || fields[field]?.belongsTo || field
|
|
@@ -196,7 +203,7 @@ export const AccessPlugin = {
|
|
|
196
203
|
return
|
|
197
204
|
}
|
|
198
205
|
|
|
199
|
-
if (scopeOptions
|
|
206
|
+
if (isOwnershipDisabled(scopeOptions)) return
|
|
200
207
|
|
|
201
208
|
const field = ownershipField()
|
|
202
209
|
const scope = scopes[scopeName]
|
|
@@ -257,7 +264,7 @@ export const AccessPlugin = {
|
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
if (context.auth.roles?.includes?.('admin')) return
|
|
260
|
-
if (scopeOptions
|
|
267
|
+
if (isOwnershipDisabled(scopeOptions)) return
|
|
261
268
|
|
|
262
269
|
const field = ownershipField()
|
|
263
270
|
const scope = scopes[scopeName]
|
|
@@ -342,7 +349,7 @@ export const AccessPlugin = {
|
|
|
342
349
|
const auth = context.originalContext?.auth || context.auth
|
|
343
350
|
if (!auth?.userId) return
|
|
344
351
|
if (auth.roles?.includes?.('admin')) return
|
|
345
|
-
if (scopeOptions
|
|
352
|
+
if (isOwnershipDisabled(scopeOptions)) return
|
|
346
353
|
|
|
347
354
|
const field = ownershipField()
|
|
348
355
|
const schemaInfo = scope?.vars?.schemaInfo || {}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export const LabelPlugin = {
|
|
2
|
+
name: 'rest-api-label',
|
|
3
|
+
dependencies: ['rest-api'],
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Install the label plugin (adds a computed 'label' attribute to all resources)
|
|
7
|
+
* @param {object} deps Injected dependencies from json-rest-api
|
|
8
|
+
* @param {function} deps.addHook - Register hooks
|
|
9
|
+
* @param {object} deps.pluginOptions - Plugin options
|
|
10
|
+
*/
|
|
11
|
+
async install ({ addHook, pluginOptions = {} }) {
|
|
12
|
+
const opts = {
|
|
13
|
+
preferNameFields: ['name', 'title'],
|
|
14
|
+
disable: false,
|
|
15
|
+
...pluginOptions
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pickLabelFieldFromFields (fields, searchSchema) {
|
|
19
|
+
const byGlobal = Object.keys(searchSchema || {}).find(k => searchSchema[k]?.globalSearch)
|
|
20
|
+
if (byGlobal) return byGlobal
|
|
21
|
+
for (const key of opts.preferNameFields) {
|
|
22
|
+
if (fields?.[key]?.type === 'string') return key
|
|
23
|
+
}
|
|
24
|
+
const firstString = Object.entries(fields || {}).find(([_, def]) => def?.type === 'string')?.[0]
|
|
25
|
+
if (firstString) return firstString
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Inject as a computed field AFTER schemas are compiled,
|
|
30
|
+
// so it lives in schemaInfo.computed and not in schemaStructure
|
|
31
|
+
addHook(
|
|
32
|
+
'scope:added',
|
|
33
|
+
'rest-api-label:inject-computed',
|
|
34
|
+
{ afterFunction: 'compileResourceSchemas' },
|
|
35
|
+
({ context, scopes }) => {
|
|
36
|
+
if (opts.disable) return
|
|
37
|
+
const { scopeName } = context
|
|
38
|
+
const scope = scopes?.[scopeName]
|
|
39
|
+
const schemaInfo = scope?.vars?.schemaInfo
|
|
40
|
+
if (!schemaInfo) return
|
|
41
|
+
|
|
42
|
+
// Respect explicit label already defined by resource
|
|
43
|
+
if (schemaInfo.computed?.label || schemaInfo.schemaStructure?.label) {
|
|
44
|
+
// If label accidentally made it into schemaStructure, remove it
|
|
45
|
+
if (schemaInfo.schemaStructure?.label) delete schemaInfo.schemaStructure.label
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const structure = schemaInfo.schemaStructure || {}
|
|
50
|
+
const searchStruct = schemaInfo.searchSchemaStructure || {}
|
|
51
|
+
const idProp = schemaInfo.idProperty || 'id'
|
|
52
|
+
|
|
53
|
+
const pick = pickLabelFieldFromFields(structure, searchStruct)
|
|
54
|
+
|
|
55
|
+
schemaInfo.computed = schemaInfo.computed || {}
|
|
56
|
+
schemaInfo.computed.label = {
|
|
57
|
+
type: 'string',
|
|
58
|
+
computed: true,
|
|
59
|
+
...(pick ? { dependencies: [pick] } : {}),
|
|
60
|
+
compute: ({ attributes }) => {
|
|
61
|
+
if (pick) {
|
|
62
|
+
const v = attributes?.[pick]
|
|
63
|
+
if (v != null) return String(v)
|
|
64
|
+
}
|
|
65
|
+
for (const k of opts.preferNameFields) {
|
|
66
|
+
if (attributes?.[k] != null) return String(attributes[k])
|
|
67
|
+
}
|
|
68
|
+
return String(attributes?.[idProp] ?? '')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1872,3 +1872,77 @@ export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
|
|
|
1872
1872
|
|
|
1873
1873
|
return api
|
|
1874
1874
|
}
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Creates an API with resources that OPT OUT of ownership
|
|
1879
|
+
* - public_items_ownerfield: uses ownerField: false
|
|
1880
|
+
* - public_items_ownership: uses ownership: false
|
|
1881
|
+
* Both tables intentionally DO NOT have a user_id column.
|
|
1882
|
+
*/
|
|
1883
|
+
export async function createOwnershipOptOutApi (knex, pluginOptions = {}) {
|
|
1884
|
+
const apiName = pluginOptions.apiName || 'ownership-optout-api'
|
|
1885
|
+
const tablePrefix = pluginOptions.tablePrefix || 'ownopt'
|
|
1886
|
+
if (storageMode.isAnyApi()) {
|
|
1887
|
+
storageMode.clearRegistry()
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const api = new Api({
|
|
1891
|
+
name: apiName,
|
|
1892
|
+
log: { level: process.env.LOG_LEVEL || 'info' }
|
|
1893
|
+
})
|
|
1894
|
+
|
|
1895
|
+
const previousTenant = storageMode.currentTenant
|
|
1896
|
+
const tenantId = storageMode.isAnyApi()
|
|
1897
|
+
? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
|
|
1898
|
+
: storageMode.defaultTenant
|
|
1899
|
+
if (storageMode.isAnyApi()) {
|
|
1900
|
+
storageMode.setCurrentTenant(tenantId)
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
await api.use(RestApiPlugin, {
|
|
1904
|
+
simplifiedApi: false,
|
|
1905
|
+
simplifiedTransport: false,
|
|
1906
|
+
returnRecordApi: { post: true, put: false, patch: false },
|
|
1907
|
+
returnRecordTransport: { post: 'full', put: 'no', patch: 'minimal' },
|
|
1908
|
+
sortableFields: ['id', 'name']
|
|
1909
|
+
})
|
|
1910
|
+
await useStoragePlugin(api, knex, { tenantId })
|
|
1911
|
+
await resetAnyApiTables(knex)
|
|
1912
|
+
|
|
1913
|
+
try {
|
|
1914
|
+
// Access plugin (defaults: ownership enabled, filterByOwner true)
|
|
1915
|
+
await api.use(AccessPlugin, pluginOptions.access || {})
|
|
1916
|
+
|
|
1917
|
+
// Resource A: disable via ownerField:false
|
|
1918
|
+
await api.addResource('public_items_ownerfield', {
|
|
1919
|
+
schema: {
|
|
1920
|
+
id: { type: 'id' },
|
|
1921
|
+
name: { type: 'string', required: true }
|
|
1922
|
+
},
|
|
1923
|
+
// Important: no user_id column in schema; ownerField disables ownership logic
|
|
1924
|
+
ownerField: false,
|
|
1925
|
+
tableName: `${tablePrefix}_public_items_of`
|
|
1926
|
+
})
|
|
1927
|
+
await api.resources.public_items_ownerfield.createKnexTable()
|
|
1928
|
+
mapTable(`${tablePrefix}_public_items_of`, 'public_items_ownerfield')
|
|
1929
|
+
|
|
1930
|
+
// Resource B: disable via ownership:false
|
|
1931
|
+
await api.addResource('public_items_ownership', {
|
|
1932
|
+
schema: {
|
|
1933
|
+
id: { type: 'id' },
|
|
1934
|
+
name: { type: 'string', required: true }
|
|
1935
|
+
},
|
|
1936
|
+
ownership: false,
|
|
1937
|
+
tableName: `${tablePrefix}_public_items_own`
|
|
1938
|
+
})
|
|
1939
|
+
await api.resources.public_items_ownership.createKnexTable()
|
|
1940
|
+
mapTable(`${tablePrefix}_public_items_own`, 'public_items_ownership')
|
|
1941
|
+
|
|
1942
|
+
return api
|
|
1943
|
+
} finally {
|
|
1944
|
+
if (storageMode.isAnyApi()) {
|
|
1945
|
+
storageMode.setCurrentTenant(previousTenant)
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, before, after, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import knexLib from 'knex'
|
|
4
|
+
import {
|
|
5
|
+
validateJsonApiStructure,
|
|
6
|
+
cleanTables,
|
|
7
|
+
createJsonApiDocument,
|
|
8
|
+
assertResourceAttributes
|
|
9
|
+
} from './helpers/test-utils.js'
|
|
10
|
+
import { createOwnershipOptOutApi } from './fixtures/api-configs.js'
|
|
11
|
+
|
|
12
|
+
const knex = knexLib({
|
|
13
|
+
client: 'better-sqlite3',
|
|
14
|
+
connection: { filename: ':memory:' },
|
|
15
|
+
useNullAsDefault: true
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
let api
|
|
19
|
+
|
|
20
|
+
async function createPublicItem (resourceName, name, authUserId) {
|
|
21
|
+
const doc = createJsonApiDocument(resourceName, { name })
|
|
22
|
+
const res = await api.resources[resourceName].post({
|
|
23
|
+
inputRecord: doc,
|
|
24
|
+
simplified: false
|
|
25
|
+
}, { auth: { userId: authUserId } })
|
|
26
|
+
|
|
27
|
+
validateJsonApiStructure(res)
|
|
28
|
+
assertResourceAttributes(res.data, { name })
|
|
29
|
+
return res.data
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('Ownership opt-out (ownerField:false / ownership:false)', () => {
|
|
33
|
+
before(async () => {
|
|
34
|
+
api = await createOwnershipOptOutApi(knex)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
after(async () => {
|
|
38
|
+
await knex.destroy()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
await cleanTables(knex, [
|
|
43
|
+
'ownopt_public_items_of',
|
|
44
|
+
'ownopt_public_items_own'
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('ownerField:false - allows create+query without user_id column present', async () => {
|
|
49
|
+
await createPublicItem('public_items_ownerfield', 'Alpha', 1)
|
|
50
|
+
await createPublicItem('public_items_ownerfield', 'Beta', 2)
|
|
51
|
+
|
|
52
|
+
// Query with auth
|
|
53
|
+
const resultAuth = await api.resources.public_items_ownerfield.query({ simplified: false }, { auth: { userId: 1 } })
|
|
54
|
+
validateJsonApiStructure(resultAuth, true)
|
|
55
|
+
assert.equal(resultAuth.data.length, 2)
|
|
56
|
+
|
|
57
|
+
// Query without auth (ownership disabled -> no filtering)
|
|
58
|
+
const resultNoAuth = await api.resources.public_items_ownerfield.query({ simplified: false })
|
|
59
|
+
validateJsonApiStructure(resultNoAuth, true)
|
|
60
|
+
assert.equal(resultNoAuth.data.length, 2)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('ownership:false - allows create+query without user_id column present', async () => {
|
|
64
|
+
await createPublicItem('public_items_ownership', 'Gamma', 1)
|
|
65
|
+
await createPublicItem('public_items_ownership', 'Delta', 2)
|
|
66
|
+
|
|
67
|
+
const resultAuth = await api.resources.public_items_ownership.query({ simplified: false }, { auth: { userId: 99 } })
|
|
68
|
+
validateJsonApiStructure(resultAuth, true)
|
|
69
|
+
assert.equal(resultAuth.data.length, 2)
|
|
70
|
+
|
|
71
|
+
const resultNoAuth = await api.resources.public_items_ownership.query({ simplified: false })
|
|
72
|
+
validateJsonApiStructure(resultNoAuth, true)
|
|
73
|
+
assert.equal(resultNoAuth.data.length, 2)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|