From d59b6b59830488b34c446c65478f58aa644f8042 Mon Sep 17 00:00:00 2001
From: fratzinger <22286818+fratzinger@users.noreply.github.com>
Date: Sun, 22 May 2022 18:50:59 +0200
Subject: [PATCH] ts: Add setField hook from feathers-authentication-hooks
(#664)
* feat(set-field): new hook 'set-field'
* chore: use assert instead of assert/strict
* docs: copy 'setField' docs from feathers-authentication-hooks
* docs: add notable changes section on overview
* docs: add jsdoc for setField
---
docs/hooks.md | 93 +++++++++++++++++++++++++
docs/overview.md | 8 ++-
src/hooks/set-field.ts | 47 +++++++++++++
src/index.ts | 3 +-
src/types.ts | 6 ++
test/hooks/set-field.test.ts | 128 +++++++++++++++++++++++++++++++++++
test/index.test.ts | 1 +
7 files changed, 284 insertions(+), 2 deletions(-)
create mode 100644 src/hooks/set-field.ts
create mode 100644 test/hooks/set-field.test.ts
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',