diff --git a/docs/hooks.md b/docs/hooks.md index d55fa81d..83a8c2da 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -1681,6 +1681,99 @@ Prune values from related records. Calculate new values. Works with fastJoin and populate. +## setField + +The `setField` hook allows to set a field on the hook context based on the value of another field on the hook context. + +|before|after|methods|multi|details| +|---|---|---|---|---| +|yes|yes|all|yes|[source](https://github.com/feathersjs-ecosystem/feathers-hooks-common/blob/master/src/hooks/set-field.ts)| + +### Options + +- `from` *required* - The property on the hook context to use. Can be an array (e.g. `[ 'params', 'user', 'id' ]`) or a dot separated string (e.g. `'params.user.id'`). +- `as` *required* - The property on the hook context to set. Can be an array (e.g. `[ 'params', 'query', 'userId' ]`) or a dot separated string (e.g. `'params.query.userId'`). +- `allowUndefined` (default: `false`) - If set to `false`, an error will be thrown if the value of `from` is `undefined` in an external request (`params.provider` is set). On internal calls (or if set to true `true` for external calls) the hook will do nothing. + +> __Important:__ This hook should be used after the [authenticate hook](https://docs.feathersjs.com/api/authentication/hook.html#authenticate-options) when accessing user fields (from `params.user`). + +### Examples + +Limit all external access of the `users` service to the authenticated user: + +> __Note:__ For MongoDB, Mongoose and NeDB `params.user.id` needs to be changed to `params.user._id`. For any other custom id accordingly. + +```js +const { authenticate } = require('@feathersjs/authentication'); +const { setField } = require('feathers-hooks-common'); + +app.service('users').hooks({ + before: { + all: [ + authenticate('jwt'), + setField({ + from: 'params.user.id', + as: 'params.query.id' + }) + ] + } +}) +``` + +Only allow access to invoices for the users organization: + +```js +const { authenticate } = require('@feathersjs/authentication'); +const { setField } = require('feathers-hooks-common'); + +app.service('invoices').hooks({ + before: { + all: [ + authenticate('jwt'), + setField({ + from: 'params.user.organizationId', + as: 'params.query.organizationId' + }) + ] + } +}) +``` + +Set the current user id as `userId` when creating a message and only allow users to edit and remove their own messages: + +```js +const { authenticate } = require('@feathersjs/authentication'); +const { setField } = require('feathers-hooks-common'); + +const setUserId = setField({ + from: 'params.user.id', + as: 'data.userId' +}); +const limitToUser = setField({ + from: 'params.user.id', + as: 'params.query.userId' +}); + +app.service('messages').hooks({ + before: { + all: [ + authenticate('jwt') + ], + create: [ + setUserId + ], + patch: [ + limitToUser + ], + update: [ + limitToUser + ] + remove: [ + limitToUser + ] + } +}) +``` ## setNow diff --git a/docs/overview.md b/docs/overview.md index 23c41942..b5ee34b8 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -7,4 +7,10 @@ This documentation has several parts: - [Hooks API](./hooks.md) - The API for the available hooks - [Utilities API](./utilities.md) - The API for the available utility methods - [Migrating](./migrating.md) - Information on how to migrate to the latest version of `feathers-hooks-common` -- [Guides](./guides.md) - More in-depth guides for some of the available hooks \ No newline at end of file +- [Guides](./guides.md) - More in-depth guides for some of the available hooks + +## Notable Changes + +### 6.1.0 + +- **new hook `setField`**: The `setField` hook allows to set a field on the hook context based on the value of another field on the hook context. [see docs](./hooks.md#setfield) diff --git a/src/hooks/set-field.ts b/src/hooks/set-field.ts new file mode 100644 index 00000000..e7f66c95 --- /dev/null +++ b/src/hooks/set-field.ts @@ -0,0 +1,47 @@ +import _get from 'lodash/get'; +import _setWith from 'lodash/setWith'; +import _clone from 'lodash/clone'; +import _debug from 'debug'; +import { checkContext } from '../utils/check-context'; +import { Forbidden } from '@feathersjs/errors'; +import type { Hook } from '@feathersjs/feathers'; +import type { SetFieldOptions } from '../types'; + +const debug = _debug('feathers-hooks-common/setField'); + +/** + * The `setField` hook allows to set a field on the hook context based on the value of another field on the hook context. + * {@link https://hooks-common.feathersjs.com/hooks.html#setfield} + */ +export function setField ( + { as, from, allowUndefined = false }: SetFieldOptions +): Hook { + if (!as || !from) { + throw new Error('\'as\' and \'from\' options have to be set'); + } + + return context => { + const { params, app } = context; + + if (app.version < '4.0.0') { + throw new Error('The \'setField\' hook only works with Feathers 4 and the latest database adapters'); + } + + checkContext(context, 'before', null, 'setField'); + + const value = _get(context, from); + + if (value === undefined) { + if (!params.provider || allowUndefined) { + debug(`Skipping call with value ${from} not set`); + return context; + } + + throw new Forbidden(`Expected field ${as} not available`); + } + + debug(`Setting value '${value}' from '${from}' as '${as}'`); + + return _setWith(context, as, value, _clone); + }; +} diff --git a/src/index.ts b/src/index.ts index b938f735..363a6bbc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export { runHook } from './utils/run-hook'; export { runParallel } from './hooks/run-parallel'; export { sequelizeConvert } from './hooks/sequelize-convert'; export { serialize } from './hooks/serialize'; +export { setField } from './hooks/set-field'; export { setNow } from './hooks/set-now'; export { setSlug } from './hooks/set-slug'; export { sifter } from './hooks/sifter'; @@ -48,4 +49,4 @@ export { paramsForServer } from './utils/params-for-server'; export { replaceItems } from './utils/replace-items'; export { some } from './utils/some'; -export * from "./types"; +export * from './types'; diff --git a/src/types.ts b/src/types.ts index 4dae35e2..7acc4400 100644 --- a/src/types.ts +++ b/src/types.ts @@ -202,3 +202,9 @@ export interface ValidateSchemaOptions extends AjvOptions { export interface IffHook extends Hook { else(...hooks: Hook[]): Hook; } + +export interface SetFieldOptions { + as: string + from: string + allowUndefined?: boolean +} diff --git a/test/hooks/set-field.test.ts b/test/hooks/set-field.test.ts new file mode 100644 index 00000000..c7048613 --- /dev/null +++ b/test/hooks/set-field.test.ts @@ -0,0 +1,128 @@ +import assert from 'assert'; +import feathers from '@feathersjs/feathers'; +import memory from 'feathers-memory'; +import { setField } from '../../src'; + +import type { Application } from '@feathersjs/feathers'; + +describe('setField', () => { + const user = { + id: 1, + name: 'David' + }; + + let app: Application; + + beforeEach(async () => { + app = feathers(); + app.use('/messages', memory()); + app.service('messages').hooks({ + before: { + all: [setField({ + from: 'params.user.id', + as: 'params.query.userId' + })] + } + }); + await app.service('messages').create({ + id: 1, + text: 'Message 1', + userId: 1 + }); + await app.service('messages').create({ + id: 2, + text: 'Message 2', + userId: 2 + }); + }); + + it('errors when options not set', () => { + assert.throws(() => app.service('messages').hooks({ + before: { + // @ts-expect-error + get: setField() + } + })); + assert.throws(() => app.service('messages').hooks({ + before: { + // @ts-expect-error + get: setField({ as: 'me' }) + } + })); + assert.throws(() => app.service('messages').hooks({ + before: { + // @ts-expect-error + get: setField({ from: 'you' }) + } + })); + }); + + it('errors when used with wrong app version', async () => { + app.version = '3.2.1'; + + await assert.rejects(async () => { + await app.service('messages').get('testing'); + }, { + message: 'The \'setField\' hook only works with Feathers 4 and the latest database adapters' + }); + }); + + it('find queries with user information, does not modify original objects', async () => { + const query = {}; + const results = await app.service('messages').find({ query, user }); + + assert.equal(results.length, 1); + assert.deepEqual(query, {}); + }); + + it('adds user information to get, throws NotFound event if record exists', async () => { + await assert.rejects(async () => { + await app.service('messages').get(2, { user }); + }, { + name: 'NotFound', + message: 'No record found for id \'2\'' + }); + + const result = await app.service('messages').get(1, { user }); + + assert.deepEqual(result, { + id: 1, + text: 'Message 1', + userId: 1 + }); + }); + + it('does nothing on internal calls if value does not exists', async () => { + const results = await app.service('messages').find(); + + assert.equal(results.length, 2); + }); + + it('errors on external calls if value does not exists', async () => { + await assert.rejects(async () => { + await app.service('messages').find({ + provider: 'rest' + }); + }, { + name: 'Forbidden', + message: 'Expected field params.query.userId not available' + }); + }); + + it('errors when not used as a before hook', async () => { + app.service('messages').hooks({ + after: { + get: setField({ + from: 'params.user.id', + as: 'params.query.userId' + }) + } + }); + + await assert.rejects(async () => { + await app.service('messages').get(1); + }, { + message: 'The \'setField\' hook can only be used as a \'before\' hook.' + }); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index 95bf43d7..d8224de6 100755 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -39,6 +39,7 @@ const members = [ 'runParallel', 'sequelizeConvert', 'serialize', + 'setField', 'setNow', 'setSlug', 'sifter',