Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ts: Add setField hook from feathers-authentication-hooks #664

Merged
merged 5 commits into from
May 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,99 @@ Prune values from related records. Calculate new values.

Works with <code>fastJoin</code> and <code>populate</code>.

## 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

Expand Down
8 changes: 7 additions & 1 deletion docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- [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)
47 changes: 47 additions & 0 deletions src/hooks/set-field.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
128 changes: 128 additions & 0 deletions test/hooks/set-field.test.ts
Original file line number Diff line number Diff line change
@@ -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.'
});
});
});
1 change: 1 addition & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const members = [
'runParallel',
'sequelizeConvert',
'serialize',
'setField',
'setNow',
'setSlug',
'sifter',
Expand Down