Writing Your Own Hooks
Hook function template
Hook functions should be written like this:
// Outer function initializes the hook function
function myHook(options) {
// The hook function itself is returned.
return context => {
// You can use the options param to condition behavior within the hook.
};
}
Feathers calls the inner function with the context object.
context.result
is an object or array for all method calls other than find
.
It is an object if the find
is paginated.
Otherwise it is an array.
The hook function may either return synchronously or it may return a Promise.
The return value (sync) or resolved value from the Promise (async) may be either
a new context object, or undefined
.
ProTip The context object is not changed if
undefined
is returned.ProTip Mutating the
context
param inside a hook function without returning it does not change the context object passed to the next hook.
Let's review the source of some of the common hooks to learn how to write our own.
debug source
debug
logs the context to the console.
export default function (msg) {
return context => {
console.log(`* ${msg || ''}\ntype:${context.type}, method: ${context.method}`);
if (context.data) { console.log('data:', context.data); }
if (context.params && context.params.query) { console.log('query:', context.params.query); }
if (context.result) { console.log('result:', context.result); }
if (context.error) { console.log('error', context.error); }
};
}
This hook is straightforward, simply displaying some of the context
object properties.
The context object does not change as the inner hook function returns undefined
by default.
debug
is great for debugging other hooks.
Once you place this hook before and after the hook under test,
you'll see the context object the test hook received, and what it returned.
This example
- Shows several context properties.
- Leaves the context object unchanged with a sync
return
.
disableMultiItemChange source
disableMultiItemChange
disables update, patch and remove methods from using null
as an id,
e.g. remove(null)
.
A null
id affects all the items in the DB, so accidentally using it may have undesirable results.
import errors from 'feathers-errors';
import checkContext from './check-context';
export default function () {
return function (context) {
checkContext(context, 'before', ['update', 'patch', 'remove'], 'disableMultiItemChange');
if (context.id === null) {
throw new errors.BadRequest(
`Multi-record changes not allowed for ${context.path} ${context.method}. (disableMultiItemChange)`
);
}
};
}
Some hooks may only be used before
or after
; some may be used only with certain methods.
The checkContext
utility
checks the hook function is being used properly.
This hook throws an error that will be properly returned to the client.
service.patch(null, data, { query: { dept: 'acct' } })
.then(data => ...)
.catch(err => {
console.log(err.message); // Multi-record changes not allowed for ...
});
This example
- Introduces
checkContext
. - Shows how to throw an error in hooks.
pluckQuery source
pluckQuery
discards all fields from the query params except for the specified ones.
This helps sanitize the query.
import _pluck from '../common/_pluck';
import checkContext from './check-context';
export default function (...fieldNames) {
return context => {
checkContext(context, 'before', null, 'pluckQuery');
const query = (context.params || {}).query || {};
context.params.query = _pluck(query, fieldNames);
return context;
};
}
The _pluck
utility, given an object and an array of property name,
returns an object consisting of just those properties.
The property names may be in dot notation, e.g. destination.address.city
.
The context object is modified and returned, thus modifying what context is passed to the next hook.
This example
- Modifies and synchronously returns the context object.
- Introduces
_pluck
.
pluck source
pluck
discards all fields except for the specified ones,
either from the data submitted or from the result.
If the data is an array or a paginated find result the hook will remove the field(s) for every item.
import _pluck from '../common/_pluck';
import checkContextIf from './check-context-if';
import getItems from './get-items';
import replaceItems from './replace-items';
export default function (...fieldNames) {
return context => {
checkContextIf(context, 'before', ['create', 'update', 'patch'], 'pluck');
if (context.params.provider) {
replaceItems(context, _pluck(getItems(context), fieldNames));
}
return context;
};
}
The getItems
utility
returns the items in either hook.data
or hook.result
depending on whether the hook is being used as a before or after hook.
hook.result.data
or hook.result
is returned for a find
method.
The returned items are always an array to simplify further processing.
The replaceItems
utility
is the reverse of getItems
, returning the items where they came from.
This example
- Introduces the convenient
getItems
andreplaceItems
utilities.
Throwing an error - disableMultiItemChange source
You will, sooner or later, want to return an error to the caller, skipping the DB call. You can do this by throwing a Feathers error.
disableMultiItemChange
disables update, patch and remove methods from using null as an id.
import errors from 'feathers-errors';
import checkContext from './check-context';
export default function () {
return function (context) {
checkContext(context, 'before', ['update', 'patch', 'remove'], 'disableMultiItemChange');
if (context.id === null) {
throw new errors.BadRequest(
`Multi-record changes not allowed for ${context.path} ${context.method}. (disableMultiItemChange)`
);
}
};
}
Feathers errors are flexible, containing useful fields. Of particular note are:
className
returns the type of error, e.g.not-found
. Your code can check this field rather than the text of the error message.errors
can return error messages for individual fields. You can customize the format to that expected by your client-side forms handler.throw new errors.BadRequest('Bad request.', { errors: { username: 'Already in use', password: 'Must be at least 8 characters long' }});
This example
- Shows how to stop a method call by throwing an error.
Returning a result
Assume that for a service with static data the record is added to cache
whenever a get
call has completed.
We can then potentially improve performance for future get
calls
by checking if we already have the record.
import { checkContext } from 'feathers-hooks-common';
export default function (cache) {
return context => {
checkContext(context, 'before', ['get'], 'memoize');
if (context.id in cache) {
context.result = cache[context.id];
return context;
}
};
};
Feathers will not make the database call if hook.result
is set.
Any remaining before and after hooks are still run.
Should this hook find a cached record,
placing it in hook.result
is the same as if the database had returned the record.
This example
- Shows how
before
hooks can determine the result for the call.
Simple async hook
Now that we've covered synchronous hooks, let's look at async ones.
Here is a simple before hook which calls an async function.
That function is supposed to determine if the values in context.data
are valid.
import errors from 'feathers-errors';
export default function (validator) {
return context => {
return validator(context.data)
.then(() => context)
.catch(errs => {
throw new errors.BadRequest('Validation error', { errors: errs });
});
};
}
The hook either returns a Promise which resolves to the existing context object, or it throws with an error object contains the errors found.
The hook after this one will not run until this Promise resolves and the hook logically ends.
ProTip Perhaps the most common error made when writing async hooks is to not return the Promise. The hook will not work as expected with
validator(context.data)
. It must bereturn validator(context.data)
.
This example
- Shows how to code async hooks.
Calling a service
Here is an after hook which attaches user info to one record (for simplicity).
export default function () {
return context => {
const service = context.app.service('users');
const item = getItems(context)[0];
if (item.userId !== null && item.userId !== undefined) {
return service.get(item.userId, context.params)
.then(data => {
item.user = data;
return context;
})
.catch(() => context);
}
};
};
context.app
is the Feathers app, socontext.app.service(path/to/service)
returns that service.- The hook returns a Promise which resolves to a mutated context, or
- the hook returns synchronously without modifying the context if there is no
userId
.
Its important that context.params
is used in the get
.
You always need to consider params
when calling a service within a hook.
If you don't assign a value, the get
will run as being called on the server
(it is being called by the server after all)
even if the method call causing the hook to be run originated on a client.
This may not be OK. The user password may be returned when a user record is read by the server, but you would not want a client to have access to it.
This hook has taken a simple approach, passing along the context.params
of the method call.
Thus the get
is run with the same context.provider
(e.g. "socketio", "rest", undefined),
context.authenticated
, etc. as the method call.
This is often satisfactory and, if not, the next example contains something more comprehensive.
ProTip Always consider
params
when doing service calls within a hook.
An interesting detail is shown here: replaceItems
is never called.
The array returned by getItems
contains the same objects as those in the context.
So changing an object in the array changes that object in the context.
This is similar to:
const foo = { name: 'John' };
const bar = [ foo ];
bar[0].project = 'Feathers';
console.log(foo); // { name: 'John', project: 'Feathers' }
This example
- Shows how to call a service.
- Shows how to deal with
params
in such calls. - Talks about using
getItems
with mutations.
stashBefore source
stashBefore
saves the current value of record before mutating it.
import errors from 'feathers-errors';
import checkContext from './check-context';
export default function (prop) {
const beforeField = prop || 'before';
return context => {
checkContext(context, 'before', ['get', 'update', 'patch', 'remove'], 'stashBefore');
if (context.id === null || context.id === undefined) {
throw new errors.BadRequest('Id is required. (stashBefore)');
}
if (context.params.query && context.params.query.$disableStashBefore) {
delete context.params.query.$disableStashBefore;
return context;
}
const params = (context.method === 'get') ? context.params : {
provider: context.params.provider,
authenticated: context.params.authenticated,
user: context.params.user
};
params.query = params.query || {};
params.query.$disableStashBefore = true;
return context.service.get(context.id, params)
.then(data => {
delete params.query.$disableStashBefore;
context.params[beforeField] = data;
return context;
})
.catch(() => context);
};
}
Its more complicated to call the hook's current service than to call another service. Let's look at some of the code in this hook.
This is what the hook returns.
return context.service.get(context.id, params)
.then(data => {
delete params.query.$disableStashBefore;
context.params[beforeField] = data;
return context;
})
.catch(() => context);
context.service
is always the current service.context.service.get()
is an async call, and it returns a Promise.- The hook returns that Promise, so its an async hook. The next hook will only run once this Promise resolves.
- The data obtained by the
get
is placed intocontext.params
. - We can see the Promise will always resolve to
context
.
In summary, the hook will get
the record being mutated by the call,
will place that record in context.params
,
and will return the possibly modified context
.
The method call will continue as if nothing has happened.
stashBefore
does not use context.params
in the get
call
as context.params
may be inappropriate if, for example, you are using Sequelize
and the method call includes parameters that are passed through to Sequelize.
What may be appropriate for an update
may not be acceptable for a get
.
const params = context.method === 'get' ? context.params : {
provider: context.params.provider,
authenticated: context.params.authenticated,
user: context.params.user
};
- On a
get
call we will use the sameparams
for our innerget
. - On other calls, we use something "safe".
- We copy over
provider
so our innerget
acts like it has the same transport. - We copy standard authentication values for auth hooks.
- We copy over
Will this satisfy every use case? No, but it will satisfy most. You can always fork the hook and customize it.
There is one more thing to consider.
The stashBefore
hook will run again when we call the inner get
.
This will cause a recursion of inner get
calls unless we do something.
if (context.params.query && context.params.query.$disableStashBefore) {
delete context.params.query.$disableStashBefore;
return context;
}
//
params.query = params.query || {};
params.query.$disableStashBefore = true;
//
delete params.query.$disableStashBefore;
We set a flag to show we are calling the inner get
.
stashBefore
will see the flag when it runs for that inner get
and exit,
preventing recursion.
ProTip Its not uncommon to indicate what state operations are in by setting flags in
params
.
This example
- Shows how to call the current service.
- Discusses how to handle
params
for service calls. - Shows how to prevent recursion.
iff, when, else
Conditional hooks like iff(predicate, hook1, hook2).else(hook3, hook4)
can be very useful.
Its easy to write your own predicates.
They are functions with a signature of context => boolean
,
which receive the context as a parameter and return either a boolean (synchronous)
or a Promise which resolves to a boolean.
You can combine predicates provided with the common hooks, such as isProvider
(source).
You can write your own, or mix and match.
iff (hook => !isProvider('server')(hook) && hook.params.user.security >= 3, ...)
The isNot
conditional utility
(source)
is useful because it will negate either a boolean or a Promise resolving to a boolean.