A highly configurable rules engine based on JSON Schema. Inspired by the popular JSON rules engine.
NBD: It actually doesn't have to use JSON Schema, but it's suggested
Lots of rules engines use custom predicates, or predicates available from other libraries. json-rules-engine uses custom Operators and json-rules-engine-simplified uses the predicate library. One thing that seems to have gotten missed is that a json schema IS a predicate - a subject will either validate against a JSON schema, or it won't. Therefore, the only thing you need to write rules is a schema validator, no other dependencies needed. The other benefit of this is that if you need to use a new operator, your dependency on this library doesn't change. You either get that logic for free when the JSON Schema specification updates, or you add that operator to your validator, but not to this rules engine itself.
This library doesn't do a whole lot - it just has an opinionated syntax to make rules human readable - which is why it's less than 2kb minzipped. You just need to bring your own validator (may we suggest Ajv?) and write your rules.
Three reasons:
- A JSON schema is a predicate
- Tools for JSON schema are everywhere and support is wide
- No dependency on second or third-party packages for logical operators. You get whatever is in the JSON schema specification, or whatever you decide to support in your validator.
- Highly configurable - use any type of schema to express your logic (we strongly suggest JSON Schema)
- Configurable interpolation to make highly reusable rules/actions
- Zero-dependency, extremely lightweight (under 2kb minzipped)
- Runs everywhere
- Nested conditions allow for controlling rule evaluation order
- Memoization makes it fast
- No thrown errors - errors are emitted, never thrown
npm install json-schema-rules-engine
# or
yarn add json-schema-rules-engine
or, use it directly in the browser
<script src="https://cdn.jsdelivr.net/npm/json-schema-rules-engine"></script>
<script>
const engine = jsonSchemaRulesEngine(validator, {
facts,
actions,
rules,
});
</script>
import Ajv from 'ajv';
import createRulesEngine from 'json-schema-rules-engine';
const facts = {
weather: async ({ query, appId, units }) => {
const url = `https://api.openweathermap.org/data/2.5/weather/?q=${q}&units=${units}&appid=${appId}`;
return (await fetch(url)).json();
},
};
const rules = {
dailyTemp: {
when: [
{
weather: {
params: {
query: '{{city}}',
appId: '{{apiKey}}',
units: '{{units}}',
},
path: 'main.temp',
is: {
type: 'number',
minimum: '{{hotTemp}}',
},
},
},
],
then: {
actions: [
{
type: 'log',
params: { message: 'Quite hot out today!' },
},
],
},
otherwise: {
actions: [
{
type: 'log',
params: { message: 'Brrr, bundle up!' },
},
],
},
},
};
const actions = {
log: console.log,
};
// validate using a JSON schema via AJV
const ajv = new Ajv();
const validator = async (subject, schema) => {
const validate = await ajv.compile(schema);
const result = validate(subject);
return { result };
};
const engine = createRulesEngine(validator, { facts, rules, actions });
engine.run({
hotTemp: 20,
city: 'Halifax',
apiKey: 'XXXX',
units: 'metric',
});
// check the console
The validator is what makes json-schema-rules-engine
so powerful. The validator is passed the resolved fact value and the schema (the value of the is
property of an evaluator
) and asynchronously returns a ValidatorResult
:
type ValidatorResult = {
result: boolean;
};
If you want to use json-schema-rules-engine
as was originally envisioned - to allow encoding of boolean logic by means of JSON Schema - then this is a great validator to use:
import Ajv from 'Ajv';
const ajv = new Ajv();
const validator = async (subject, schema) => {
const validate = await ajv.compile(schema);
const result = validate(subject);
return { result };
};
const engine = createRulesEngine(validator);
You can see by abstracting the JSON Schema part away from the core rules engine (by means of the validator
) this engine can actually use anything to evaluate a property against. The validator is why json-schema-rules-engine
is so small and so powerful.
context
is the name of the object the rules engine evaluates during run
. It can be used for interpolation or even as a source of facts
const context = {
hotTemp: 20,
city: 'Halifax',
apiKey: 'XXXX',
units: 'metric',
};
engine.run(context);
There are two types of facts - static and functional. Functional facts come from the facts given to the rule engine when it is created (or via setFacts). They are unary functions that return a value, synchronously or asynchronously. Check out this example weather fact that calls an the openweather api and returns the JSON response.
const weather = async ({ query, appId, units }) => {
const url = `https://api.openweathermap.org/data/2.5/weather/?q=${q}&units=${units}&appid=${appId}`;
return (await fetch(url)).json();
};
Static facts are simply the values of the context object
It's important to note that all functional facts are memoized during an individual run of the rule engine - but not between runs - based on shallow equality of their argument. This is to ensure that the same functional fact can be evaluated in multiple rules without that fact being called more than once (useful for aysnchronous facts to prevent multiple API calls).
This means that functions that accept an argument that contains values that are objects or arrays are not memoized by default. But this can be configured using something like lodash's isEqual
import _ from 'lodash';
const engine = createRulesEngine(validator, { memoizer: _.isEqual });
If you want any of your facts to be memoized between runs, feel free to use our memoization helpers before setting the facts
import _ from 'lodash';
import { memo, memoRecord } from 'json-schema-rules-engine/memo';
// memoize a single function
const memoizedFunction = memo((...args) => {
/* ... */
});
// deep equal memoize
const deeplyMemoizedFunction = memo((...args) => {
/* ... */
}, _.isEqual);
// memoize an object whos values are functions
const memoizedFacts = memoRecord({
weather: async (...args) => {
/* ... */
},
});
const deeplyMemoizedFacts = memoRecord(
{
weather: async (...args) => {
/* ... */
},
},
_.isEqual,
);
engine.setFacts(memoizedFacts);
If, for some reason, you do not want facts to be memoized during a run, then you can just pass a stub memoizer:
const engine = createRulesEngine(validator, { memoizer: () => false });
Actions, just like facts, are unary functions. They can be sync or async and can do anything. They are executed as an outcome of a rule.
const saveAuditRecord = async ({ eventType, data }) => {
await db.insert('INSERT INTO audit_log (event, data) VALUES(?,?)', [
eventType,
data,
]);
};
const engine = createRulesEngine(validator, { actions: saveAuditRecord });
Rules are written as when, then, otherwise. A when clause consists of an array of FactMap
s, or an object whose values are FactMap
s. If any of the FactMap
s in the object or array evaluate to true, the properties of the then
clause of the rule are evaluated. If not, the otherwise
clause is evaluated.
const myRule = {
when: [
{
age: {
is: {
type: 'number',
minimum: 30,
},
},
name: {
is: {
type: 'string',
pattern: '^J',
},
},
},
],
then: {
actions: [
{
type: 'log',
params: {
message: 'Hi {{name}}!',
},
},
],
},
};
const engine = createRulesEngine(validator, { rules: { myRule } });
engine.run({ age: 31, name: 'Fred' }); // no action is fired
engine.run({ age: 32, name: 'Joe' }); // fires the log action with { message: 'Hi Joe!' }
The then
or otherwise
property can consist of either actions
, but it can also contain a nested rule. All functional facts in all FactMaps are evaluated simultaneously. By nesting when
's, you can cause facts to be executed serially.
const myRule = {
when: [
{
weather: {
params: {
query: '{{city}}',
appId: '{{apiKey}}',
units: '{{units}}',
},
path: 'main.temp',
is: {
type: 'number',
minimum: 30
}
},
},
],
then: {
when: [
{
forecast: {
params: {
appId: '{{apiKey}}',
coord: '{{results[0].weather.value.coord}}' // interpolate a value returned from the first fact
},
path: 'daily',
is: {
type: 'array',
contains: {
type: 'object',
properties: {
temp: {
type: 'object',
properties: {
max: {
type: 'number',
minimum: 20
}
}
}
}
},
minContains: 4
}
}
},
then: {
actions: {
type: 'log',
params: {
message: 'Nice week of weather coming up',
}
}
}
],
actions: [
{
type: 'log',
params: {
message: 'Warm one today',
},
},
],
},
};
A FactMap is a plain object whose keys are facts (static or functional) and values are Evaluator
's.
An evaluator is an object that specifies a JSON Schema to evaluate a fact against. If the fact is a functional fact, the evaluator can specify params to pass to the fact as an argument. A path
can also be specified to more easily evaluate a nested property contained within the fact.
The following weather fact evaluator passes parameters to the function and specifies a schema to check the value at main.temp
against:
const myFactMap = {
weather: {
params: {
query: '{{city}}',
appId: '{{apiKey}}',
units: '{{units}}',
},
path: 'main.temp',
is: {
type: 'number',
minimum: '{{hotTemp}}',
},
},
};
By default, json-schema-rules-engine
uses dot notation - like property-expr or lodash's get - to retrieve an inner value from an object or array via path
. This can be changed by the resolver
option. For example, if you wanted to use json pointer, you could do it like this:
import { get } from 'jsonpointer';
const engine = createRulesEngine(validator, { resolver: get });
engine.setRules({
myRule: {
weather: {
params: {
query: '{{/city}}',
appId: '{{/apiKey}}',
units: '{{/units}}',
},
path: '/main/temp',
is: {
type: 'number',
minimum: '{{/hotTemp}}',
},
},
},
});
NOTE: the resolver
is also used to retrieve values for interpolation
. If using jsonpointer
notation, this means that interpolations must be prefixed with a /
.
Interpolation is configurable by passing the pattern
option. By default, it uses the handlebars-style pattern of {{variable}}
.
Anything passed in via the context object given to engine.run
is available to be interpolated anywhere in a rule.
In addition to context
, actions have a special property called results
that can be used for interpolation in then
and otherwise
clauses.
The (top level) when
clause of a rule can interpolate things from context
. But the then
and otherwise
have a special property available to them called results
that you can interpolate. This is where defining FactMap as arrays or objects also comes into play. Consider the following rule:
const rules = {
dailyTemp: {
when: [
{
weather: {
params: {
query: '{{city}}',
appId: '{{apiKey}}',
units: '{{units}}',
},
path: 'main.temp',
is: {
type: 'number',
minimum: '{{hotTemp}}',
},
},
},
],
then: {
actions: [
{
type: 'log',
params: {
message:
'Quite hot out today - going to be {{results[0].weather.resolved}}!',
},
},
],
},
otherwise: {
actions: [
{
type: 'log',
params: {
message:
'Brrr, bundle up - only going to be {{resilts[0].weather.resolved}}',
},
},
],
},
},
};
If we were to name the FactMap using an object instead of an array, we could use the key of the FactMap for the interpolation:
const rules = {
dailyTemp: {
when: {
myWeatherCondition: {
weather: {
params: {
query: '{{city}}',
appId: '{{apiKey}}',
units: '{{units}}',
},
path: 'main.temp',
is: {
type: 'number',
minimum: '{{hotTemp}}',
},
},
},
},
then: {
actions: [
{
type: 'log',
params: {
message:
'Quite hot out today - going to be {{results.myWeatherCondition.weather.resolved}}!',
},
},
],
},
},
};
Two things to note:
- The
results
variable is local to the rule that it's operating in. Different rules have different results. - There are two properties on the fact name (
weather
in the above case):value
- the value returned from the function (or the value from context if using a static fact)resolved
- the value being evaluated. If there is nopath
, value andresolved
are the same
The rules engine is also an event emitter. There are 4 types of events you can listen to
Emitted as soon as you call run
on the engine
engine.on('start', ({ context, facts, rules, actions }) => {
/* ... */
});
Emitted when all rules have been evaluated AND all actions have been executed
engine.on('complete', ({ context, results }) => {
/* ... */
});
Useful to monitor the internal execution and evaluation of facts and actions
engine.on('debug', ({ type, ...rest }) => {
/* ... */
});
Any errors thrown during fact execution/evaluation or action execution are emitted via error
engine.on('error', ({ type, ...rest }) => {
/* ... */
});
The errors that can be emitted are:
FactExecutionError
- errors thrown during the execution of functional factsFactEvaluationError
- errors thrown during the evaluation of facts/results from factsActionExecutionError
- errors thrown during the execution of actions
createRulesEngine(validator: Validator, options?: Options): RulesEngine
type Options = {
facts?: Record<string,Fact>;
rules?: Record<string,Rule>;
actions?: Record<string,Action>;
pattern?: RegExp; // for interpolation
memoizer?: <T>(a: T, b: T) => boolean;
resolver?: (subject: Record<string,any>, path: string) => any
};
interface RulesEngine {
setRules(rulesPatch: Patch<Rules>): void;
setFacts(factsPatch: Patch<Facts>): void;
setActions(actionsPatch: Patch<Actions>): void;
on('debug', subscriber: DebugSubscriber): Unsubscribe
on('error', subscriber: ErrorSubscriber): Unsubscribe
on('start', subscriber: StartSubscriber): Unsubscribe
on('complete', subscriber: CompleteSubscriber): Unsubscribe
run(context: Record<string, any>): Promise<EngineResults>;
}
type Unsubscribe = () => void;
type PatchFunction<T> = (o: T) => T;
type Patch<T> = PatchFunction<T> | Partial<T>;
Help wanted! I'd like to create really great advanced types around the content of the facts, actions, and context given to the engine. Reach out @akmjenkins or akmjenkins@gmail.com