Skip to content

Commit c3260b9

Browse files
committed
refine API
1 parent 3a90876 commit c3260b9

File tree

12 files changed

+243
-269
lines changed

12 files changed

+243
-269
lines changed

README.md

Lines changed: 20 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ const actions = {
8888
// validate using a JSON schema via AJV
8989
const ajv = new Ajv();
9090
const validator = async (subject, schema) => {
91-
const validate = ajv.compile(schema);
92-
const result = await validate(subject);
91+
const validate = await ajv.compile(schema);
92+
const result = validate(subject);
9393
return { result };
9494
};
9595

@@ -119,50 +119,30 @@ engine.run({
119119

120120
## Validator
121121

122-
The thing about `json-schema-rules-engine` is that you don't have to use JSON schema (but you are highly encouraged to!)
122+
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 returns (optionally asynchronously) a `ValidatorResult`:
123123

124-
You **must** provide a validator when creating a rules engine. We haven't provided one in the interest of keeping this package unopinionated and small, but here's a great one to use:
124+
```ts
125+
type ValidatorResult = {
126+
result: boolean;
127+
};
128+
```
129+
130+
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:
125131

126132
```js
127133
import Ajv from 'Ajv';
128134
const ajv = new Ajv();
129135
const validator = async (subject, schema) => {
130-
const validate = ajv.compile(schema);
131-
const result = await validate(subject);
136+
const validate = await ajv.compile(schema);
137+
const result = validate(subject);
132138
return { result };
133139
};
134-
```
135140

136-
The validator must return an object with a `result` key that has a `boolean`. It can run async. It is used to evaluate a fact at runtime, and is passed the fact value and the schema (or otherwise serializable JSON) you have defined in your rules
137-
138-
```js
139-
const rule = {
140-
myRule: {
141-
when: [
142-
{
143-
firstName: {
144-
is: {
145-
type: 'string',
146-
pattern: '^Joe',
147-
},
148-
},
149-
},
150-
];
151-
}
152-
}
153-
154-
engine.run({firstName: 'Bill'})
155-
156-
// the validator you provided is called like this:
157-
const { result, ...rest } = validator(
158-
'Bill',
159-
{
160-
type: 'string',
161-
pattern: '^Joe',
162-
}
163-
);
141+
const engine = createRulesEngine(validator);
164142
```
165143

144+
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.
145+
166146
### Context
167147

168148
`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
@@ -253,8 +233,8 @@ The `then` or `otherwise` property can consist of either `actions`, but it can a
253233
const myRule = {
254234
when: [
255235
{
236+
id: 'weatherCondition',
256237
weather: {
257-
name: 'myWeatherFact',
258238
params: {
259239
query: '{{city}}',
260240
appId: '{{apiKey}}',
@@ -274,7 +254,7 @@ const myRule = {
274254
forecast: {
275255
params: {
276256
appId: '{{apiKey}}',
277-
coord: '{{results.myWeatherFact.value.coord}}' // interpolate a value returned from the first fact
257+
coord: '{{results.weatherCondition.weather.value.coord}}' // interpolate a value returned from the first fact
278258
},
279259
path: 'daily',
280260
is: {
@@ -320,7 +300,9 @@ const myRule = {
320300

321301
#### FactMap
322302

323-
A fact map is a plain object whose keys are facts (static or functional) and values are [`Evaluator`'s](#evaluator)
303+
A fact map is a plain object whose keys are facts (static or functional) and values are [`Evaluator`'s](#evaluator).
304+
305+
NOTE: `id` is a reserved word in a `FactMap`. It is used internally to allow easy access to the results of a `FactMap` for interpolation in the `then` or `otherwise` clauses.
324306

325307
#### Evaluator
326308

@@ -345,8 +327,6 @@ const myFactMap = {
345327
};
346328
```
347329

348-
You can also specify a `name` as a way to more easily interpolate the result from the
349-
350330
### Interpolation
351331

352332
Interpolation is configurable by passing the `pattern` option. By default, it uses [handlebars](https://handlebarsjs.com/)

src/action.executor.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const createActionExecutor =
2+
({ actions }) =>
3+
async ({ type, params }) => {
4+
const action = actions[type];
5+
if (!action) throw new Error(`No action found for ${type}`);
6+
return action(params);
7+
};

src/engine.js

Lines changed: 18 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -1,213 +1,22 @@
1-
import { interpolateDeep } from './interpolate';
21
import { defaults } from './options';
3-
import { patch, memoRecord } from './utils';
2+
import { patch } from './utils';
3+
import { createJob } from './job';
44

5-
const createFactEvaluator =
6-
(context, facts, { validator, resolver }, emit) =>
7-
(rule, index) =>
8-
async ([factName, { name, params, path, is }]) => {
9-
emit('debug', {
10-
type: 'STARTING_FACT',
11-
fact: factName,
12-
rule,
13-
index,
14-
params,
15-
});
16-
const fact = facts[factName] || context[factName];
17-
try {
18-
const value = await (typeof fact === 'function' ? fact(params) : fact);
19-
const resolved = path ? resolver(value, path) : value;
20-
emit('debug', {
21-
type: 'EXECUTED_FACT',
22-
fact: factName,
23-
rule,
24-
index,
25-
params,
26-
value,
27-
resolved,
28-
});
29-
try {
30-
const result = await validator(resolved, is);
31-
emit('debug', {
32-
type: 'EVALUATED_FACT',
33-
fact: factName,
34-
rule,
35-
index,
36-
value,
37-
resolved,
38-
is,
39-
result,
40-
});
41-
return {
42-
factName,
43-
name,
44-
value,
45-
resolved,
46-
...result,
47-
};
48-
} catch (error) {
49-
emit('error', {
50-
type: 'FactEvaluationError',
51-
rule,
52-
error,
53-
context,
54-
factName,
55-
value,
56-
resolved,
57-
path,
58-
is,
59-
});
60-
return { error: true };
61-
}
62-
} catch (error) {
63-
emit('error', {
64-
type: 'FactExecutionError',
65-
rule,
66-
error,
67-
context,
68-
factName,
69-
params,
70-
});
71-
return { error: true };
72-
}
73-
};
74-
75-
const createFactmapChecker = (context, facts, options, emit) => {
76-
const evaluator = createFactEvaluator(context, facts, options, emit);
77-
return (rule) => {
78-
return async (factMap, index) => {
79-
emit('debug', {
80-
type: 'STARTING_FACT_MAP',
81-
rule,
82-
index,
83-
});
84-
const results = await Promise.all(
85-
Object.entries(factMap).map(evaluator(rule, index)),
86-
);
87-
return results.reduce(
88-
(acc, { factName, ...rest }) => ({ ...acc, [factName]: rest }),
89-
{},
90-
);
91-
};
92-
};
93-
};
94-
95-
const getRuleResults = (results) => {
96-
return Object.values(results).reduce(
97-
(acc, resultMap) => {
98-
if (acc.error) return acc;
99-
const error = Object.values(resultMap).some(({ error }) => error);
100-
return {
101-
error,
102-
passed:
103-
!error &&
104-
acc.passed &&
105-
Object.values(resultMap).every(({ result }) => result),
106-
};
107-
},
108-
{
109-
passed: true,
110-
error: false,
111-
},
112-
);
113-
};
114-
115-
const getResultsContext = (results) =>
116-
results.reduce(
117-
(acc, r) =>
118-
Object.values(r).reduce(
119-
(a, { name, ...rest }) => (name ? { ...a, [name]: rest } : a),
120-
acc,
121-
),
122-
{ ...results },
123-
);
124-
125-
const createActionExecutor = (actions, opts, emit) => (what, context) =>
126-
Promise.all(
127-
interpolateDeep(what, context, opts.pattern, opts.resolver).map(
128-
async ({ type, params }) => {
129-
try {
130-
if (!actions[type]) throw new Error(`No action found for ${type}`);
131-
await actions[type](params);
132-
} catch (error) {
133-
emit('error', {
134-
type: 'ActionExecutionError',
135-
action: type,
136-
params,
137-
error,
138-
});
139-
}
140-
},
141-
),
142-
);
143-
144-
const createRuleRunner = (context, facts, actions, opts, emit) => {
145-
const checker = createFactmapChecker(context, facts, opts, emit);
146-
const executor = createActionExecutor(actions, opts, emit);
147-
return async ([rule, { when, ...rest }]) => {
148-
const interpolatedRule = interpolateDeep(
149-
when,
150-
context,
151-
opts.pattern,
152-
opts.resolver,
153-
);
154-
emit('debug', {
155-
type: 'STARTING_RULE',
156-
rule,
157-
interpolated: interpolatedRule,
158-
});
159-
const results = await Promise.all(interpolatedRule.map(checker(rule)));
160-
const { passed, error } = getRuleResults(results);
161-
const resultsContext = getResultsContext(results);
162-
emit('debug', {
163-
type: 'FINISHED_RULE',
164-
rule,
165-
results,
166-
passed,
167-
error,
168-
context: resultsContext,
169-
});
170-
if (error) return;
171-
const key = passed ? 'then' : 'otherwise';
172-
const which = rest[key];
173-
if (!which) return;
174-
const nextContext = {
175-
...context,
176-
results: { ...(context.results || {}), ...resultsContext },
177-
};
178-
179-
return Promise.all([
180-
which.when
181-
? createRuleRunner(
182-
nextContext,
183-
facts,
184-
actions,
185-
opts,
186-
emit,
187-
)([`${rule}.${key}`, which])
188-
: null,
189-
which.actions ? executor(which.actions, nextContext) : null,
190-
]);
191-
};
192-
};
193-
194-
export const createRulesEngine = ({
195-
facts = {},
196-
actions = {},
197-
rules = {},
198-
...options
199-
} = {}) => {
5+
export const createRulesEngine = (
6+
validator,
7+
{ facts = {}, actions = {}, rules = {}, ...options } = {},
8+
) => {
2009
options = { ...defaults, ...options };
20110

202-
if (!options.validator) throw new Error('A validator is required');
11+
if (!validator) throw new Error('A validator is required');
20312

20413
const eventMap = new Map();
20514

20615
const emit = (event, a) => (eventMap.get(event) || []).forEach((s) => s(a));
20716

20817
const on = (event, subscriber) => {
209-
if (!eventMap.get(event)) eventMap.set(event, new Set());
210-
eventMap.get(event).add(subscriber);
18+
const set = eventMap.get(event);
19+
set ? eventMap.set(event, new Set([subscriber])) : set.add(subscriber);
21120
return () => eventMap.get(event).delete(subscriber);
21221
};
21322

@@ -217,15 +26,18 @@ export const createRulesEngine = ({
21726
setRules: (next) => (rules = patch(next, rules)),
21827
run: async (context = {}) => {
21928
emit('start', { context, facts, rules, actions });
220-
const runner = createRuleRunner(
29+
const execute = createJob(validator, {
30+
...options,
22131
context,
222-
memoRecord(facts),
32+
facts,
33+
rules,
22334
actions,
224-
options,
22535
emit,
226-
);
227-
await Promise.all(Object.entries(rules).map(runner));
228-
emit('complete', { context });
36+
});
37+
38+
const results = await execute();
39+
emit('complete', { context, results });
40+
return results;
22941
},
23042
on,
23143
};

src/evaluator.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const createEvaluator =
2+
(validator, opts, emit, rule) =>
3+
(id) =>
4+
async ([factName, { params, path, is }]) => {
5+
const onError = (params) => emit('error', { ...params, rule, id });
6+
7+
const fact = opts.facts[factName] || opts.context[factName];
8+
try {
9+
const value = await (typeof fact === 'function' ? fact(params) : fact);
10+
const resolved = path ? opts.resolver(value, path) : value;
11+
try {
12+
const result = await validator(resolved, is);
13+
return { factName, ...result, value, resolved };
14+
} catch (error) {
15+
onError({ type: 'FactEvaluationError', path, is, resolved });
16+
}
17+
} catch (error) {
18+
onError({ type: 'FactExecutionError', params });
19+
}
20+
21+
return { factName, error: true };
22+
};

0 commit comments

Comments
 (0)