├── .eslintrc.js
├── .github
└── workflows
│ ├── publish.yaml
│ └── test.yaml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── babel.config.js
├── jest.config.js
├── package.json
├── rollup.config.js
├── src
├── action.executor.js
├── engine.js
├── evaluator.js
├── fact.map.processor.js
├── index.d.ts
├── index.js
├── interpolate.js
├── job.js
├── memo.d.ts
├── memo.js
├── options.js
├── parse.results.js
├── rule.runner.js
└── utils.js
├── test
├── engine.test.ts
├── events.test.ts
├── facts.ts
├── fixtures.ts
├── memo.test.ts
├── rules.ts
└── validators.ts
├── tsconfig.json
└── yarn.lock
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: 'babel-eslint',
3 | env: {
4 | es6: true,
5 | node: true,
6 | browser: true,
7 | },
8 | parserOptions: {
9 | ecmaVersion: 6,
10 | sourceType: 'module',
11 | },
12 | settings: {
13 | 'import/resolver': {
14 | node: {
15 | paths: ['./src'],
16 | },
17 | },
18 | },
19 | extends: [
20 | 'eslint:recommended',
21 | 'plugin:prettier/recommended',
22 | 'plugin:import/errors',
23 | 'plugin:import/warnings',
24 | ],
25 | rules: {
26 | semi: ['error', 'always', { omitLastInOneLineBlock: true }],
27 | 'no-console': ['error'],
28 | 'no-unused-vars': 0,
29 | 'spaced-comment': ['error', 'always'],
30 | 'keyword-spacing': ['error', { after: true }],
31 | 'lines-between-class-members': ['error', 'always'],
32 | 'object-property-newline': [
33 | 'error',
34 | { allowAllPropertiesOnSameLine: true },
35 | ],
36 | quotes: ['error', 'single', { allowTemplateLiterals: true }],
37 | 'import/no-unresolved': [2, { commonjs: true, amd: true }],
38 | 'import/named': 2,
39 | 'import/namespace': 0,
40 | 'import/no-self-import': 2,
41 | 'import/first': 2,
42 | 'import/order': [
43 | 'error',
44 | {
45 | 'newlines-between': 'never',
46 | },
47 | ],
48 | 'import/no-named-as-default': 0,
49 | },
50 | overrides: [
51 | {
52 | files: ['**/*.ts'],
53 | parser: '@typescript-eslint/parser',
54 | plugins: ['@typescript-eslint'],
55 | extends: [
56 | 'plugin:@typescript-eslint/eslint-recommended',
57 | 'plugin:@typescript-eslint/recommended',
58 | ],
59 | settings: {
60 | 'import/parsers': {
61 | '@typescript-eslint/parser': ['.ts', '.tsx'],
62 | },
63 | 'import/resolver': {
64 | typescript: {},
65 | },
66 | },
67 | rules: {
68 | '@typescript-eslint/explicit-module-boundary-types': 0,
69 | },
70 | },
71 | {
72 | files: ['**/test/**/*.[t|j]s'],
73 | env: {
74 | es6: true,
75 | jest: true,
76 | node: true,
77 | },
78 | rules: {
79 | 'no-unused-vars': 0,
80 | 'jest/no-disabled-tests': 0,
81 | 'jest/no-focused-tests': 'error',
82 | 'jest/no-identical-title': 'error',
83 | 'jest/prefer-to-have-length': 'warn',
84 | 'jest/valid-expect': 'error',
85 | },
86 | extends: [
87 | 'eslint:recommended',
88 | 'plugin:import/errors',
89 | 'plugin:import/warnings',
90 | 'plugin:jest/recommended',
91 | ],
92 | },
93 | ],
94 | };
95 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 |
14 | - uses: actions/cache@v2
15 | with:
16 | path: '**/node_modules'
17 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
18 | - run: yarn
19 | - run: yarn build
20 | - uses: JS-DevTools/npm-publish@v1
21 | with:
22 | token: ${{ secrets.NPM_TOKEN }}
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v2
14 |
15 | - uses: actions/cache@v2
16 | with:
17 | path: '**/node_modules'
18 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
19 |
20 | - name: Install packages
21 | run: yarn
22 |
23 | - name: Run Lint
24 | run: yarn lint
25 |
26 | - name: Run Tests
27 | run: yarn test --coverage
28 |
29 | - name: Codecov
30 | uses: codecov/codecov-action@v2
31 |
32 | - name: Create Build
33 | run: yarn build
34 |
35 |
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | coverage
4 | yarn-error.log
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "trailingComma": "all"
6 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Adam
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # JSON Schema Rules Engine
2 |
3 | [](https://npmjs.org/package/json-schema-rules-engine)
4 | [](https://codecov.io/gh/akmjenkins/json-schema-rules-engine)
5 | 
6 | [](https://bundlephobia.com/result?p=json-schema-rules-engine)
7 |
8 | A highly configurable rules engine based on [JSON Schema](https://json-schema.org/). Inspired by the popular [JSON rules engine](https://github.com/CacheControl/json-rules-engine).
9 |
10 | _NBD: It actually doesn't **have** to use JSON Schema, but it's suggested_
11 |
12 | ## Preface
13 |
14 | Lots of rules engines use custom predicates, or predicates available from other libraries. [json-rules-engine](https://github.com/CacheControl/json-rules-engine) uses custom [Operators](https://github.com/CacheControl/json-rules-engine/blob/master/src/engine-default-operators.js) and [json-rules-engine-simplified](https://github.com/RxNT/json-rules-engine-simplified) uses the [predicate](https://github.com/landau/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](#validator), but not to this rules engine itself.
15 |
16 | 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](https://github.com/ajv-validator/ajv)?) and write your rules.
17 |
18 | ## Why?
19 |
20 | Three reasons:
21 |
22 | 1. A JSON schema **is a predicate**
23 | 2. Tools for JSON schema are everywhere and support is wide
24 | 3. 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](#validator).
25 |
26 | ## Features
27 |
28 | - Highly configurable - use any type of schema to express your logic (we strongly suggest JSON Schema)
29 | - Configurable interpolation to make highly reusable rules/actions
30 | - Zero-dependency, extremely lightweight (under 2kb minzipped)
31 | - Runs everywhere
32 | - Nested conditions allow for controlling rule evaluation order
33 | - [Memoization makes it fast](#memoization)
34 | - No thrown errors - errors are emitted, never thrown
35 |
36 | ## Installation
37 |
38 | ```bash
39 | npm install json-schema-rules-engine
40 | # or
41 | yarn add json-schema-rules-engine
42 | ```
43 |
44 | or, use it directly in the browser
45 |
46 | ```html
47 |
48 |
55 | ```
56 |
57 | ## Basic Example
58 |
59 | ```js
60 | import Ajv from 'ajv';
61 | import createRulesEngine from 'json-schema-rules-engine';
62 |
63 | const facts = {
64 | weather: async ({ query, appId, units }) => {
65 | const url = `https://api.openweathermap.org/data/2.5/weather/?q=${q}&units=${units}&appid=${appId}`;
66 | return (await fetch(url)).json();
67 | },
68 | };
69 |
70 | const rules = {
71 | dailyTemp: {
72 | when: [
73 | {
74 | weather: {
75 | params: {
76 | query: '{{city}}',
77 | appId: '{{apiKey}}',
78 | units: '{{units}}',
79 | },
80 | path: 'main.temp',
81 | is: {
82 | type: 'number',
83 | minimum: '{{hotTemp}}',
84 | },
85 | },
86 | },
87 | ],
88 | then: {
89 | actions: [
90 | {
91 | type: 'log',
92 | params: { message: 'Quite hot out today!' },
93 | },
94 | ],
95 | },
96 | otherwise: {
97 | actions: [
98 | {
99 | type: 'log',
100 | params: { message: 'Brrr, bundle up!' },
101 | },
102 | ],
103 | },
104 | },
105 | };
106 |
107 | const actions = {
108 | log: console.log,
109 | };
110 |
111 | // validate using a JSON schema via AJV
112 | const ajv = new Ajv();
113 | const validator = async (subject, schema) => {
114 | const validate = await ajv.compile(schema);
115 | const result = validate(subject);
116 | return { result };
117 | };
118 |
119 | const engine = createRulesEngine(validator, { facts, rules, actions });
120 |
121 | engine.run({
122 | hotTemp: 20,
123 | city: 'Halifax',
124 | apiKey: 'XXXX',
125 | units: 'metric',
126 | });
127 |
128 | // check the console
129 | ```
130 |
131 | ## Concepts
132 |
133 | - [Validator](#validator)
134 | - [Context](#context)
135 | - [Facts](#facts)
136 | - [Actions](#actions)
137 | - [Rules](#rules)
138 | - [Nesting](#nesting-rules)
139 | - [FactMap](#factmap)
140 | - [Evaluator](#evaluator)
141 | - [Resolver](#resolver)
142 | - [Interpolation](#interpolation)
143 | - [Results Context](#results-context)
144 | - [Events](#events)
145 |
146 | ## Validator
147 |
148 | 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`](#evaluator)) and asynchronously returns a `ValidatorResult`:
149 |
150 | ```ts
151 | type ValidatorResult = {
152 | result: boolean;
153 | };
154 | ```
155 |
156 | 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:
157 |
158 | ```js
159 | import Ajv from 'Ajv';
160 | const ajv = new Ajv();
161 | const validator = async (subject, schema) => {
162 | const validate = await ajv.compile(schema);
163 | const result = validate(subject);
164 | return { result };
165 | };
166 |
167 | const engine = createRulesEngine(validator);
168 | ```
169 |
170 | 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.
171 |
172 | ### Context
173 |
174 | `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
175 |
176 | ```js
177 | const context = {
178 | hotTemp: 20,
179 | city: 'Halifax',
180 | apiKey: 'XXXX',
181 | units: 'metric',
182 | };
183 |
184 | engine.run(context);
185 | ```
186 |
187 | ### Facts
188 |
189 | 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](`setFacts`)). They are unary functions that return a value, synchronously or asynchronously. Check out this example weather fact that calls an the [openweather api](https://openweathermap.org/api) and returns the JSON response.
190 |
191 | ```js
192 | const weather = async ({ query, appId, units }) => {
193 | const url = `https://api.openweathermap.org/data/2.5/weather/?q=${q}&units=${units}&appid=${appId}`;
194 | return (await fetch(url)).json();
195 | };
196 | ```
197 |
198 | Static facts are simply the values of the context object
199 |
200 | #### Memoization
201 |
202 | 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).
203 |
204 | 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](https://lodash.com/docs/4.17.15#isEqual)
205 |
206 | ```js
207 | import _ from 'lodash';
208 |
209 | const engine = createRulesEngine(validator, { memoizer: _.isEqual });
210 | ```
211 |
212 | If you want any of your facts to be memoized **between** runs, feel free to use our memoization helpers before setting the facts
213 |
214 | ```js
215 | import _ from 'lodash';
216 | import { memo, memoRecord } from 'json-schema-rules-engine/memo';
217 |
218 | // memoize a single function
219 | const memoizedFunction = memo((...args) => {
220 | /* ... */
221 | });
222 |
223 | // deep equal memoize
224 | const deeplyMemoizedFunction = memo((...args) => {
225 | /* ... */
226 | }, _.isEqual);
227 |
228 | // memoize an object whos values are functions
229 | const memoizedFacts = memoRecord({
230 | weather: async (...args) => {
231 | /* ... */
232 | },
233 | });
234 |
235 | const deeplyMemoizedFacts = memoRecord(
236 | {
237 | weather: async (...args) => {
238 | /* ... */
239 | },
240 | },
241 | _.isEqual,
242 | );
243 |
244 | engine.setFacts(memoizedFacts);
245 | ```
246 |
247 | If, for some reason, you do not want facts to be memoized during a run, then you can just pass a stub memoizer:
248 |
249 | ```js
250 | const engine = createRulesEngine(validator, { memoizer: () => false });
251 | ```
252 |
253 | ### Actions
254 |
255 | 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.
256 |
257 | ```js
258 | const saveAuditRecord = async ({ eventType, data }) => {
259 | await db.insert('INSERT INTO audit_log (event, data) VALUES(?,?)', [
260 | eventType,
261 | data,
262 | ]);
263 | };
264 |
265 | const engine = createRulesEngine(validator, { actions: saveAuditRecord });
266 | ```
267 |
268 | ### Rules
269 |
270 | Rules are written as **when**, **then**, **otherwise**. A when clause consists of an array of [`FactMap`s](#factmap), or an object whose values are [`FactMap`s](#factmap). 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.
271 |
272 | ```js
273 | const myRule = {
274 | when: [
275 | {
276 | age: {
277 | is: {
278 | type: 'number',
279 | minimum: 30,
280 | },
281 | },
282 | name: {
283 | is: {
284 | type: 'string',
285 | pattern: '^J',
286 | },
287 | },
288 | },
289 | ],
290 | then: {
291 | actions: [
292 | {
293 | type: 'log',
294 | params: {
295 | message: 'Hi {{name}}!',
296 | },
297 | },
298 | ],
299 | },
300 | };
301 |
302 | const engine = createRulesEngine(validator, { rules: { myRule } });
303 | engine.run({ age: 31, name: 'Fred' }); // no action is fired
304 | engine.run({ age: 32, name: 'Joe' }); // fires the log action with { message: 'Hi Joe!' }
305 | ```
306 |
307 | #### Nesting Rules
308 |
309 | The `then` or `otherwise` property can consist of either `actions`, but it can also contain a nested rule. All functional facts in all [FactMaps](#factmaps) are evaluated simultaneously. By nesting `when`'s, you can cause facts to be executed serially.
310 |
311 | ```js
312 | const myRule = {
313 | when: [
314 | {
315 | weather: {
316 | params: {
317 | query: '{{city}}',
318 | appId: '{{apiKey}}',
319 | units: '{{units}}',
320 | },
321 | path: 'main.temp',
322 | is: {
323 | type: 'number',
324 | minimum: 30
325 | }
326 | },
327 | },
328 | ],
329 | then: {
330 | when: [
331 | {
332 | forecast: {
333 | params: {
334 | appId: '{{apiKey}}',
335 | coord: '{{results[0].weather.value.coord}}' // interpolate a value returned from the first fact
336 | },
337 | path: 'daily',
338 | is: {
339 | type: 'array',
340 | contains: {
341 | type: 'object',
342 | properties: {
343 | temp: {
344 | type: 'object',
345 | properties: {
346 | max: {
347 | type: 'number',
348 | minimum: 20
349 | }
350 | }
351 | }
352 | }
353 | },
354 | minContains: 4
355 | }
356 | }
357 | },
358 | then: {
359 | actions: {
360 | type: 'log',
361 | params: {
362 | message: 'Nice week of weather coming up',
363 | }
364 | }
365 | }
366 | ],
367 | actions: [
368 | {
369 | type: 'log',
370 | params: {
371 | message: 'Warm one today',
372 | },
373 | },
374 | ],
375 | },
376 | };
377 | ```
378 |
379 | #### FactMap
380 |
381 | A FactMap is a plain object whose keys are facts (static or functional) and values are [`Evaluator`'s](#evaluator).
382 |
383 | #### Evaluator
384 |
385 | 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.
386 |
387 | The following weather fact evaluator passes parameters to the function and specifies a schema to check the value at `main.temp` against:
388 |
389 | ```js
390 | const myFactMap = {
391 | weather: {
392 | params: {
393 | query: '{{city}}',
394 | appId: '{{apiKey}}',
395 | units: '{{units}}',
396 | },
397 | path: 'main.temp',
398 | is: {
399 | type: 'number',
400 | minimum: '{{hotTemp}}',
401 | },
402 | },
403 | };
404 | ```
405 |
406 | ### Resolver
407 |
408 | By default, `json-schema-rules-engine` uses dot notation - like [property-expr](https://github.com/jquense/expr) or [lodash's get](https://lodash.com/docs/4.17.15#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](https://www.npmjs.com/package/jsonpointer), you could do it like this:
409 |
410 | ```js
411 | import { get } from 'jsonpointer';
412 |
413 | const engine = createRulesEngine(validator, { resolver: get });
414 |
415 | engine.setRules({
416 | myRule: {
417 | weather: {
418 | params: {
419 | query: '{{/city}}',
420 | appId: '{{/apiKey}}',
421 | units: '{{/units}}',
422 | },
423 | path: '/main/temp',
424 | is: {
425 | type: 'number',
426 | minimum: '{{/hotTemp}}',
427 | },
428 | },
429 | },
430 | });
431 | ```
432 |
433 | **NOTE:** the `resolver` is also used to retrieve values for [`interpolation`](#interpolation). If using `jsonpointer` notation, this means that interpolations must be prefixed with a `/`.
434 |
435 | ### Interpolation
436 |
437 | Interpolation is configurable by passing the `pattern` option. By default, it uses the [handlebars](https://handlebarsjs.com/)-style pattern of `{{variable}}`.
438 |
439 | Anything passed in via the context object given to `engine.run` is available to be interpolated _anywhere_ in a rule.
440 |
441 | In addition to `context`, actions have a special property called [`results`](#results-context) that can be used for interpolation in `then` and `otherwise` clauses.
442 |
443 | #### Results Context
444 |
445 | 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:
446 |
447 | ```js
448 | const rules = {
449 | dailyTemp: {
450 | when: [
451 | {
452 | weather: {
453 | params: {
454 | query: '{{city}}',
455 | appId: '{{apiKey}}',
456 | units: '{{units}}',
457 | },
458 | path: 'main.temp',
459 | is: {
460 | type: 'number',
461 | minimum: '{{hotTemp}}',
462 | },
463 | },
464 | },
465 | ],
466 | then: {
467 | actions: [
468 | {
469 | type: 'log',
470 | params: {
471 | message:
472 | 'Quite hot out today - going to be {{results[0].weather.resolved}}!',
473 | },
474 | },
475 | ],
476 | },
477 | otherwise: {
478 | actions: [
479 | {
480 | type: 'log',
481 | params: {
482 | message:
483 | 'Brrr, bundle up - only going to be {{resilts[0].weather.resolved}}',
484 | },
485 | },
486 | ],
487 | },
488 | },
489 | };
490 | ```
491 |
492 | 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:
493 |
494 | ```js
495 | const rules = {
496 | dailyTemp: {
497 | when: {
498 | myWeatherCondition: {
499 | weather: {
500 | params: {
501 | query: '{{city}}',
502 | appId: '{{apiKey}}',
503 | units: '{{units}}',
504 | },
505 | path: 'main.temp',
506 | is: {
507 | type: 'number',
508 | minimum: '{{hotTemp}}',
509 | },
510 | },
511 | },
512 | },
513 | then: {
514 | actions: [
515 | {
516 | type: 'log',
517 | params: {
518 | message:
519 | 'Quite hot out today - going to be {{results.myWeatherCondition.weather.resolved}}!',
520 | },
521 | },
522 | ],
523 | },
524 | },
525 | };
526 | ```
527 |
528 | Two things to note:
529 |
530 | 1. The `results` variable is local to the rule that it's operating in. Different rules have different results.
531 | 2. There are two properties on the fact name (`weather` in the above case):
532 | - `value` - the value returned from the function (or the value from context if using a static fact)
533 | - `resolved` - the value being evaluated. If there is no `path`, value and `resolved` are the same
534 |
535 | ### Events
536 |
537 | The rules engine is also an event emitter. There are 4 types of events you can listen to
538 |
539 | - [start](#start)
540 | - [complete](#complete)
541 | - [debug](#debug)
542 | - [error](#error)
543 |
544 | ### start
545 |
546 | Emitted as soon as you call `run` on the engine
547 |
548 | ```js
549 | engine.on('start', ({ context, facts, rules, actions }) => {
550 | /* ... */
551 | });
552 | ```
553 |
554 | ### complete
555 |
556 | Emitted when all rules have been evaluated AND all actions have been executed
557 |
558 | ```js
559 | engine.on('complete', ({ context, results }) => {
560 | /* ... */
561 | });
562 | ```
563 |
564 | ### debug
565 |
566 | Useful to monitor the internal execution and evaluation of facts and actions
567 |
568 | ```js
569 | engine.on('debug', ({ type, ...rest }) => {
570 | /* ... */
571 | });
572 | ```
573 |
574 | ### error
575 |
576 | Any errors thrown during fact execution/evaluation or action execution are emitted via `error`
577 |
578 | ```js
579 | engine.on('error', ({ type, ...rest }) => {
580 | /* ... */
581 | });
582 | ```
583 |
584 | The errors that can be emitted are:
585 |
586 | - `FactExecutionError` - errors thrown during the execution of functional facts
587 | - `FactEvaluationError` - errors thrown during the evaluation of facts/results from facts
588 | - `ActionExecutionError` - errors thrown during the execution of actions
589 |
590 | ## API/Types
591 |
592 | - **`createRulesEngine(validator: Validator, options?: Options): RulesEngine`**
593 |
594 | ```ts
595 | type Options = {
596 | facts?: Record;
597 | rules?: Record;
598 | actions?: Record;
599 | pattern?: RegExp; // for interpolation
600 | memoizer?: (a: T, b: T) => boolean;
601 | resolver?: (subject: Record, path: string) => any
602 | };
603 |
604 | interface RulesEngine {
605 | setRules(rulesPatch: Patch): void;
606 | setFacts(factsPatch: Patch): void;
607 | setActions(actionsPatch: Patch): void;
608 | on('debug', subscriber: DebugSubscriber): Unsubscribe
609 | on('error', subscriber: ErrorSubscriber): Unsubscribe
610 | on('start', subscriber: StartSubscriber): Unsubscribe
611 | on('complete', subscriber: CompleteSubscriber): Unsubscribe
612 | run(context: Record): Promise;
613 | }
614 |
615 | type Unsubscribe = () => void;
616 |
617 | type PatchFunction = (o: T) => T;
618 | type Patch = PatchFunction | Partial;
619 | ```
620 |
621 | ## License
622 |
623 | [MIT](./LICENSE)
624 |
625 | ## Contributing
626 |
627 | 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](https://twitter.com/akmjenkins) or [akmjenkins@gmail.com](mailto:akmjenkins@gmail.com)
628 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ['add-module-exports'],
3 | presets: [
4 | ['@babel/preset-env', { targets: { node: 'current', browsers: '>1%' } }],
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transform: {
3 | '^.+\\.jsx?$': require.resolve('babel-jest'),
4 | '^.+\\.ts?$': 'ts-jest',
5 | },
6 | transformIgnorePatterns: ['node_modules/(?!(node-fetch|fetch-blob)/)'],
7 | collectCoverageFrom: ['./src/*.js'],
8 | coverageThreshold: {
9 | global: {
10 | branches: 85,
11 | functions: 95,
12 | lines: 95,
13 | },
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "json-schema-rules-engine",
3 | "version": "1.2.0",
4 | "description": "Rules engine based on JSON Schema",
5 | "main": "build/index.js",
6 | "author": "Adam Jenkins",
7 | "license": "MIT",
8 | "browser": "build/bundle.min.js",
9 | "types": "build/index.d.ts",
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/akmjenkins/json-schema-rules-engine"
13 | },
14 | "bugs": {
15 | "url": "https://github.com/akmjenkins/json-schema-rules-engine/issues"
16 | },
17 | "keywords": [
18 | "json schema",
19 | "rules engine"
20 | ],
21 | "files": [
22 | "build"
23 | ],
24 | "scripts": {
25 | "clean": "rimraf build",
26 | "build": "yarn clean && yarn babel && rollup -c",
27 | "babel": "babel src -d build --copy-files --no-copy-ignored",
28 | "lint": "eslint src/",
29 | "test": "jest"
30 | },
31 | "devDependencies": {
32 | "@babel/cli": "^7.15.7",
33 | "@babel/core": "^7.15.0",
34 | "@babel/preset-env": "^7.15.0",
35 | "@rollup/plugin-babel": "^5.3.0",
36 | "@rollup/plugin-node-resolve": "^13.0.4",
37 | "@types/jest": "^27.0.1",
38 | "@types/lodash": "^4.14.172",
39 | "@types/node": "^16.7.10",
40 | "@typescript-eslint/eslint-plugin": "^4.30.0",
41 | "@typescript-eslint/parser": "^4.30.0",
42 | "ajv": "^8.6.2",
43 | "babel-eslint": "^10.1.0",
44 | "babel-jest": "^27.1.0",
45 | "babel-plugin-add-module-exports": "^1.0.4",
46 | "eslint": "^7.32.0",
47 | "eslint-config-prettier": "^8.3.0",
48 | "eslint-import-resolver-typescript": "^2.4.0",
49 | "eslint-plugin-import": "^2.24.2",
50 | "eslint-plugin-jest": "^24.4.0",
51 | "eslint-plugin-prettier": "^4.0.0",
52 | "jest": "^27.1.0",
53 | "jsonpointer": "^5.0.0",
54 | "lodash": "^4.17.21",
55 | "prettier": "^2.3.2",
56 | "rollup": "^2.56.3",
57 | "rollup-plugin-sourcemaps": "^0.6.3",
58 | "rollup-plugin-terser": "^7.0.2",
59 | "ts-jest": "^27.0.5",
60 | "typescript": "^4.4.2"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import { nodeResolve } from '@rollup/plugin-node-resolve';
2 | import { terser } from 'rollup-plugin-terser';
3 | import sourcemaps from 'rollup-plugin-sourcemaps';
4 | import { babel } from '@rollup/plugin-babel';
5 |
6 | export default {
7 | input: 'src/index.js',
8 | output: [
9 | {
10 | sourcemap: true,
11 | file: 'build/bundle.min.js',
12 | format: 'iife',
13 | name: 'jsonSchemaRulesEngine',
14 | plugins: [terser()],
15 | },
16 | ],
17 | plugins: [nodeResolve(), babel({ babelHelpers: 'bundled' }), sourcemaps()],
18 | };
19 |
--------------------------------------------------------------------------------
/src/action.executor.js:
--------------------------------------------------------------------------------
1 | import { interpolateDeep } from './interpolate';
2 |
3 | export const createActionExecutor =
4 | ({ actions, pattern, resolver }, emit) =>
5 | (toExecute, nextContext, rule) =>
6 | Promise.all(
7 | interpolateDeep(toExecute, nextContext, pattern, resolver).map(
8 | async (action) => {
9 | const { type, params } = action;
10 | try {
11 | const fn = actions[type];
12 | if (!fn) throw new Error(`No action found for ${type}`);
13 | return { ...action, result: await fn(params) };
14 | } catch (error) {
15 | emit('error', {
16 | type: 'ActionExecutionError',
17 | rule,
18 | action: type,
19 | error,
20 | params,
21 | });
22 | return { ...action, error };
23 | }
24 | },
25 | ),
26 | );
27 |
--------------------------------------------------------------------------------
/src/engine.js:
--------------------------------------------------------------------------------
1 | import { defaults } from './options';
2 | import { patch } from './utils';
3 | import { createJob } from './job';
4 |
5 | export const createRulesEngine = (
6 | validator,
7 | { facts = {}, actions = {}, rules = {}, ...options } = {},
8 | ) => {
9 | options = { ...defaults, ...options };
10 |
11 | if (!validator) throw new Error('A validator is required');
12 |
13 | const eventMap = new Map();
14 |
15 | const emit = (event, a) => (eventMap.get(event) || []).forEach((s) => s(a));
16 |
17 | const on = (event, subscriber) => {
18 | const set = eventMap.get(event);
19 | set ? set.add(subscriber) : eventMap.set(event, new Set([subscriber]));
20 | return () => eventMap.get(event).delete(subscriber);
21 | };
22 |
23 | return {
24 | setFacts: (next) => (facts = patch(next, facts)),
25 | setActions: (next) => (actions = patch(next, actions)),
26 | setRules: (next) => (rules = patch(next, rules)),
27 | run: async (context = {}) => {
28 | emit('start', { context, facts, rules, actions });
29 | const execute = createJob(
30 | validator,
31 | {
32 | ...options,
33 | context,
34 | facts,
35 | rules,
36 | actions,
37 | },
38 | emit,
39 | );
40 |
41 | const results = await execute();
42 | emit('complete', { context, results });
43 | return results;
44 | },
45 | on,
46 | };
47 | };
48 |
--------------------------------------------------------------------------------
/src/evaluator.js:
--------------------------------------------------------------------------------
1 | export const createEvaluator =
2 | (validator, opts, emit, rule) =>
3 | (mapId) =>
4 | async ([factName, { params, path, is }]) => {
5 | emit('debug', { type: 'STARTING_FACT', rule, mapId, factName });
6 | const onError = (params) =>
7 | emit('error', { ...params, factName, rule, mapId });
8 |
9 | const fact = opts.facts[factName] || opts.context[factName];
10 | try {
11 | const value = await (typeof fact === 'function' ? fact(params) : fact);
12 | const resolved = path ? opts.resolver(value, path) : value;
13 | emit('debug', {
14 | type: 'EXECUTED_FACT',
15 | rule,
16 | mapId,
17 | path,
18 | factName,
19 | value,
20 | resolved,
21 | });
22 | try {
23 | const result = await validator(resolved, is);
24 | emit('debug', {
25 | type: 'EVALUATED_FACT',
26 | rule,
27 | mapId,
28 | path,
29 | factName,
30 | value,
31 | resolved,
32 | is,
33 | result,
34 | });
35 | return { factName, ...result, value, resolved };
36 | } catch (error) {
37 | onError({
38 | type: 'FactEvaluationError',
39 | error,
40 | path,
41 | is,
42 | value,
43 | resolved,
44 | });
45 | }
46 | } catch (error) {
47 | onError({ type: 'FactExecutionError', error, params });
48 | }
49 |
50 | return { factName, error: true };
51 | };
52 |
--------------------------------------------------------------------------------
/src/fact.map.processor.js:
--------------------------------------------------------------------------------
1 | import { createEvaluator } from './evaluator';
2 |
3 | export const createFactMapProcessor = (validator, opts, emit) => (rule) => {
4 | const evaluator = createEvaluator(validator, opts, emit, rule);
5 | return async (factMap, mapId) => {
6 | emit('debug', { type: 'STARTING_FACT_MAP', rule, mapId, factMap });
7 |
8 | // flags for if there was an error processing the fact map
9 | // and if all evaluations in the fact map passed
10 | let error = false;
11 | let passed = true;
12 |
13 | const results = (
14 | await Promise.all(Object.entries(factMap).map(evaluator(mapId)))
15 | ).reduce((acc, { factName, ...rest }) => {
16 | if (error) return acc;
17 | error = error || !!rest.error;
18 | passed = !error && passed && rest.result;
19 | acc[factName] = rest;
20 | return acc;
21 | }, {});
22 |
23 | emit('debug', {
24 | type: 'FINISHED_FACT_MAP',
25 | rule,
26 | mapId,
27 | results,
28 | passed,
29 | error,
30 | });
31 |
32 | return {
33 | [mapId]: {
34 | ...results,
35 | __passed: passed,
36 | __error: error,
37 | },
38 | };
39 | };
40 | };
41 |
--------------------------------------------------------------------------------
/src/index.d.ts:
--------------------------------------------------------------------------------
1 | type UnaryFunction = (arg: any) => MaybePromise;
2 |
3 | export type Facts = Record;
4 | export type Actions = Record;
5 | export type Rules = Record;
6 | export type Rule = {
7 | when: FactMap[] | NamedFactMap;
8 | then?: RuleActions | Rule | (Rule & RuleActions);
9 | otherwise?: RuleActions | Rule | (Rule & RuleActions);
10 | };
11 |
12 | type MaybePromise = T | Promise;
13 |
14 | type Action = {
15 | type: string;
16 | params?: unknown;
17 | };
18 |
19 | type RuleActions = {
20 | actions: Action[];
21 | };
22 |
23 | interface NamedFactMap {
24 | [named: string]: FactMap;
25 | }
26 |
27 | interface FactMap {
28 | [fact: string]: Evaluator;
29 | }
30 |
31 | export type Evaluator = {
32 | params?: unknown;
33 | path?: string;
34 | is: Record;
35 | };
36 |
37 | interface ValidatorResult {
38 | result: boolean;
39 | }
40 |
41 | type Validator = (subject: any, schema: any) => MaybePromise;
42 |
43 | export type Unsubscribe = () => void;
44 |
45 | type Options = {
46 | facts?: Facts;
47 | actions?: Actions;
48 | rules?: Rules;
49 | pattern?: RegExp;
50 | memoizer?: (a: T, b: T) => boolean;
51 | resolver?: (subject: any, path: string) => any;
52 | };
53 |
54 | export type JobConstruct = EngineOptions & {
55 | rules: Rules;
56 | facts: Facts;
57 | actions: Actions;
58 | context: Context;
59 | };
60 |
61 | type StartingRuleEvent = {
62 | type: 'STARTING_RULE';
63 | rule: string;
64 | interpolated: FactMap[] | NamedFactMap;
65 | context: Context;
66 | };
67 |
68 | type FinishedRuleEvent = {
69 | type: 'FINISHED_RULE';
70 | rule: string;
71 | interpolated: FactMap[] | NamedFactMap;
72 | context: Context;
73 | result: RuleResult;
74 | };
75 |
76 | type StartingFactMapEvent = {
77 | type: 'STARTING_FACT_MAP';
78 | rule: string;
79 | mapId: string | number;
80 | factMap: FactMap;
81 | };
82 |
83 | type FinishedFactMapEvent = {
84 | type: 'FINISHED_FACT_MAP';
85 | rule: string;
86 | mapId: string | number;
87 | results: FactMapResult;
88 | passed: boolean;
89 | error: boolean;
90 | };
91 |
92 | type StartingFactEvent = {
93 | type: 'STARTING_FACT';
94 | rule: string;
95 | mapId: string | number;
96 | factName: string;
97 | };
98 |
99 | type ExecutedFactEvent = {
100 | type: 'EXECUTED_FACT';
101 | rule: string;
102 | mapId: string | number;
103 | factName: string;
104 | params?: any;
105 | path?: string;
106 | value: any;
107 | resolved: any;
108 | };
109 |
110 | type EvaluatedFactEvent = {
111 | type: 'EVALUATED_FACT';
112 | rule: string;
113 | mapId: string | number;
114 | factName: string;
115 | value: any;
116 | resolved: any;
117 | is: Record;
118 | result: ValidatorResult;
119 | };
120 |
121 | type RuleParseError = {
122 | type: 'RuleParsingError';
123 | rule: string;
124 | error: Error;
125 | };
126 |
127 | type FactEvaluationError = {
128 | type: 'FactEvaluationError';
129 | rule: string;
130 | mapId: string | number;
131 | factName: string;
132 | error: Error;
133 | context: Context;
134 | factName: string;
135 | value: any;
136 | resolved: any;
137 | path?: string;
138 | is: Record;
139 | };
140 |
141 | type FactExecutionError = {
142 | type: 'FactExecutionError';
143 | rule: string;
144 | mapId: string | number;
145 | factName: string;
146 | error: Error;
147 | context: Context;
148 | factName: string;
149 | params?: Record;
150 | };
151 |
152 | type ActionExecutionError = {
153 | type: 'ActionExecutionError';
154 | rule: string;
155 | action: string;
156 | params?: Record;
157 | error: Error;
158 | };
159 |
160 | type FactMapResult = {
161 | [key: string]: {
162 | result: boolean;
163 | value: any;
164 | resolved: any;
165 | };
166 | };
167 |
168 | type RuleResult = {
169 | actions?: Record;
170 | results?: Record>;
171 | };
172 |
173 | type EngineResults = Record;
174 |
175 | export type DebugEvent =
176 | | StartingFactMapEvent
177 | | StartingFactEvent
178 | | ExecutedFactEvent
179 | | EvaluatedFactEvent
180 | | StartingRuleEvent
181 | | FinishedRuleEvent;
182 | export type ErrorEvent =
183 | | FactEvaluationError
184 | | FactExecutionError
185 | | ActionExecutionError;
186 | export type StartEvent = {
187 | context: Context;
188 | facts: Facts;
189 | rules: Rules;
190 | actions: Actions;
191 | };
192 | export type CompleteEvent = {
193 | context: Context;
194 | results: EngineResults;
195 | };
196 |
197 | export type DebugSubscriber = (event: DebugEvent) => void;
198 | export type ErrorSubscriber = (event: ErrorEvent) => void;
199 | export type StartSubscriber = (event: StartEvent) => void;
200 |
201 | export type EventType = 'debug' | 'start' | 'complete' | 'error';
202 |
203 | export type EventMap = {
204 | debug: DebugSubscriber;
205 | error: ErrorSubscriber;
206 | };
207 |
208 | export type CompleteSubscriber = (event: CompleteEvent) => void;
209 |
210 | type Subscriber =
211 | | DebugSubscriber
212 | | ErrorSubscriber
213 | | StartSubscriber
214 | | CompleteSubscriber;
215 |
216 | export type Context = Record;
217 |
218 | type PatchFunction = (o: T) => T;
219 |
220 | type Patch = PatchFunction | Partial;
221 |
222 | export interface RulesEngine {
223 | setRules(rules: Patch): void;
224 | setActions(actions: Patch): void;
225 | setFacts(facts: Patch): void;
226 | run(context?: Context): Promise;
227 | on(event: 'debug', subscriber: DebugSubscriber): Unsubscribe;
228 | on(event: 'start', subscriber: StartSubscriber): Unsubscribe;
229 | on(event: 'complete', subscriber: CompleteSubscriber): Unsubscribe;
230 | on(event: 'error', subscriber: ErrorSubscriber): Unsubscribe;
231 | }
232 |
233 | export default function createRulesEngine(
234 | validator: Validator,
235 | options?: Options,
236 | ): RulesEngine;
237 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import { createRulesEngine } from './engine';
2 | export default createRulesEngine;
3 |
--------------------------------------------------------------------------------
/src/interpolate.js:
--------------------------------------------------------------------------------
1 | export const interpolate = (subject = '', params = {}, match, resolver) => {
2 | let shouldReplaceFull, found;
3 |
4 | const replaced = subject.replace(match, (full, matched) => {
5 | shouldReplaceFull = full === subject;
6 | found = resolver(params, matched);
7 | return shouldReplaceFull ? '' : found;
8 | });
9 |
10 | return shouldReplaceFull ? found : replaced;
11 | };
12 |
13 | export const interpolateDeep = (o, params, matcher, resolver) => {
14 | if (!o || typeof o === 'number' || typeof o === 'boolean') return o;
15 |
16 | if (typeof o === 'string') return interpolate(o, params, matcher, resolver);
17 |
18 | if (Array.isArray(o))
19 | return o.map((t) => interpolateDeep(t, params, matcher, resolver));
20 |
21 | return Object.entries(o).reduce(
22 | (acc, [k, v]) => ({
23 | ...acc,
24 | [k]: interpolateDeep(v, params, matcher, resolver),
25 | }),
26 | {},
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/job.js:
--------------------------------------------------------------------------------
1 | import { createRuleRunner } from './rule.runner';
2 | import { memoRecord } from './memo';
3 |
4 | export const createJob = (validator, opts, emit) => {
5 | const { rules, facts, ...options } = opts;
6 | const memoed = memoRecord(facts, opts.memoizer);
7 | const checkRule = createRuleRunner(
8 | validator,
9 | { ...options, facts: memoed },
10 | emit,
11 | );
12 |
13 | return async () =>
14 | (await Promise.all(Object.entries(rules).map(checkRule))).reduce(
15 | (a, r) => ({ ...a, ...r }),
16 | {},
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/memo.d.ts:
--------------------------------------------------------------------------------
1 | type AnyFunc = (...args: any[]) => unknown;
2 |
3 | export function memo(
4 | func: T,
5 | equalityCheck?: (a: T, b: T) => boolean,
6 | ): T;
7 |
8 | export function memoRecord>(
9 | map: T,
10 | equalityCheck?: (a: S, b: S) => boolean,
11 | ): T;
12 |
--------------------------------------------------------------------------------
/src/memo.js:
--------------------------------------------------------------------------------
1 | import { shallowEqual } from './utils';
2 | export const memo =
3 | (fn, check = shallowEqual, args, last) =>
4 | (...inner) =>
5 | args &&
6 | inner.length === args.length &&
7 | inner.every((a, i) => check(a, args[i]))
8 | ? last
9 | : (last = fn(...(args = inner)));
10 |
11 | export const memoRecord = (record, check) =>
12 | Object.entries(record).reduce(
13 | (acc, [k, v]) => ({ ...acc, [k]: memo(v, check) }),
14 | {},
15 | );
16 |
--------------------------------------------------------------------------------
/src/options.js:
--------------------------------------------------------------------------------
1 | import { get } from './utils';
2 | const pattern = /\{\{\s*(.+?)\s*\}\}/g;
3 | const resolver = get;
4 |
5 | const defaults = { pattern, resolver };
6 | export { defaults };
7 |
--------------------------------------------------------------------------------
/src/parse.results.js:
--------------------------------------------------------------------------------
1 | export const parseResults = (r) =>
2 | r.reduce(
3 | (acc, result) => {
4 | acc.passed =
5 | acc.passed || Object.values(result).every(({ __passed }) => __passed);
6 | acc.error =
7 | acc.error && Object.values(result).some(({ __error }) => __error);
8 | acc.results = { ...acc.results, ...result };
9 | return acc;
10 | },
11 | { passed: false, error: true, results: {} },
12 | );
13 |
--------------------------------------------------------------------------------
/src/rule.runner.js:
--------------------------------------------------------------------------------
1 | import { createFactMapProcessor } from './fact.map.processor';
2 | import { createActionExecutor } from './action.executor';
3 | import { interpolateDeep } from './interpolate';
4 | import { parseResults } from './parse.results';
5 |
6 | export const createRuleRunner = (validator, opts, emit) => {
7 | const processor = createFactMapProcessor(validator, opts, emit);
8 | const executor = createActionExecutor(opts, emit);
9 | const { context, pattern, resolver } = opts;
10 | return async ([rule, { when, ...rest }]) => {
11 | try {
12 | // interpolated can be an array FactMap[] OR an object NamedFactMap
13 | const interpolated = interpolateDeep(when, context, pattern, resolver);
14 | emit('debug', { type: 'STARTING_RULE', rule, interpolated, context });
15 |
16 | const process = processor(rule);
17 |
18 | const { passed, error, results } = parseResults(
19 | await Promise.all(
20 | Array.isArray(interpolated)
21 | ? interpolated.map(process)
22 | : Object.entries(interpolated).map(async ([k, v]) => process(v, k)),
23 | ),
24 | );
25 |
26 | const ret = (rest = {}) => ({
27 | [rule]: { error, passed, ...rest, results },
28 | });
29 |
30 | const key = passed ? 'then' : 'otherwise';
31 | const which = rest[key];
32 | if (error || !which) return ret();
33 | const nextContext = { ...context, results };
34 | const { actions = [], when: nextWhen } = which;
35 | const [actionResults, nestedResults] = await Promise.all([
36 | executor(actions, nextContext, rule).then((actionResults) => {
37 | // we've effectively finished this rule. The nested rules, if any, will print their own debug messages (I think this is acceptable behavior?)
38 | emit('debug', {
39 | type: 'FINISHED_RULE',
40 | rule,
41 | interpolated,
42 | context,
43 | result: { actions: actionResults, results },
44 | });
45 | return actionResults;
46 | }),
47 | nextWhen
48 | ? createRuleRunner(
49 | validator,
50 | { ...opts, context: nextContext },
51 | emit,
52 | )([`${rule}.${key}`, which])
53 | : null,
54 | ]);
55 | const toRet = ret({ actions: actionResults });
56 | return nestedResults ? { ...toRet, ...nestedResults } : toRet;
57 | } catch (error) {
58 | emit('error', { type: 'RuleExecutionError', error });
59 | return { [rule]: { error: true } };
60 | }
61 | };
62 | };
63 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | export const patch = (o, w) =>
2 | typeof o === 'function' ? o(w) : { ...w, ...o };
3 |
4 | // inlined from property-expr
5 | const SPLIT_REGEX = /[^.^\]^[]+|(?=\[\]|\.\.)/g;
6 | const CLEAN_QUOTES_REGEX = /^\s*(['"]?)(.*?)(\1)\s*$/; // utility to dynamically destructure arrays
7 |
8 | const parts = (path) =>
9 | path.match(SPLIT_REGEX).map((p) => p.replace(CLEAN_QUOTES_REGEX, '$2'));
10 |
11 | export const get = (obj, path) =>
12 | parts(path).reduce((acc, part) => (!acc ? acc : acc[part]), obj);
13 |
14 | export const shallowEqual = (a, b) => {
15 | if (a === b) return true;
16 | if (!a || !b) return false;
17 | const keysA = Object.keys(a);
18 | if (keysA.length !== Object.keys(b).length) return false;
19 | return keysA.every((k) => a[k] === b[k]);
20 | };
21 |
--------------------------------------------------------------------------------
/test/engine.test.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { get } from 'jsonpointer';
3 | import createRulesEngine, { RulesEngine } from '../src';
4 | import { createAjvValidator } from './validators';
5 |
6 | describe('rules engine', () => {
7 | const validator = jest.fn(createAjvValidator());
8 | let engine: RulesEngine;
9 | let log: jest.Mock;
10 | let call: jest.Mock;
11 |
12 | beforeEach(() => {
13 | log = jest.fn();
14 | call = jest.fn();
15 | engine = createRulesEngine(validator, { actions: { log, call } });
16 | });
17 |
18 | it('should execute a rule', async () => {
19 | const rules = {
20 | salutation: {
21 | when: [
22 | {
23 | firstName: { is: { type: 'string', pattern: '^J' } },
24 | },
25 | ],
26 | then: {
27 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
28 | },
29 | otherwise: {
30 | actions: [{ type: 'call', params: { message: 'Who are you?' } }],
31 | },
32 | },
33 | };
34 | engine.setRules(rules);
35 | await engine.run({ firstName: 'John' });
36 | expect(log).toHaveBeenCalledWith({ message: 'Hi friend!' });
37 | log.mockClear();
38 | await engine.run({ firstName: 'Bill' });
39 | expect(log).not.toHaveBeenCalled();
40 | expect(call).toHaveBeenCalledWith({ message: 'Who are you?' });
41 | });
42 |
43 | it('should execute a rule using a named fact map', async () => {
44 | const rules = {
45 | salutation: {
46 | when: {
47 | myFacts: {
48 | firstName: { is: { type: 'string', pattern: '^J' } },
49 | },
50 | },
51 | then: {
52 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
53 | },
54 | otherwise: {
55 | actions: [{ type: 'call', params: { message: 'Who are you?' } }],
56 | },
57 | },
58 | };
59 | engine.setRules(rules);
60 | await engine.run({ firstName: 'John' });
61 | expect(log).toHaveBeenCalledWith({ message: 'Hi friend!' });
62 | log.mockClear();
63 | await engine.run({ firstName: 'Bill' });
64 | expect(log).not.toHaveBeenCalled();
65 | expect(call).toHaveBeenCalledWith({ message: 'Who are you?' });
66 | });
67 |
68 | it('should process nested rules', async () => {
69 | const rules = {
70 | salutation: {
71 | when: [
72 | {
73 | firstName: { is: { type: 'string', pattern: '^A' } },
74 | },
75 | ],
76 | then: {
77 | when: [
78 | {
79 | lastName: { is: { type: 'string', pattern: '^J' } },
80 | },
81 | ],
82 | then: {
83 | actions: [
84 | {
85 | type: 'log',
86 | params: { message: 'You have the same initials as me!' },
87 | },
88 | ],
89 | },
90 | otherwise: {
91 | actions: [{ type: 'log', params: { message: 'Hi' } }],
92 | },
93 | },
94 | },
95 | };
96 | engine.setRules(rules);
97 | await engine.run({ firstName: 'Andrew' });
98 | expect(log).toHaveBeenCalledWith({ message: 'Hi' });
99 | log.mockClear();
100 | await engine.run({ firstName: 'Andrew', lastName: 'Jackson' });
101 | expect(log).toHaveBeenCalledWith({
102 | message: 'You have the same initials as me!',
103 | });
104 | });
105 |
106 | it('should memoize facts', async () => {
107 | const facts = { f1: jest.fn() };
108 | engine.setFacts(facts);
109 | engine.setRules({
110 | rule1: {
111 | when: [
112 | { f1: { params: { a: 1 }, is: {} } },
113 | { f1: { params: { a: 1 }, is: {} } },
114 | { f1: { params: { a: 2 }, is: {} } },
115 | ],
116 | },
117 | });
118 | await engine.run();
119 | expect(facts.f1).toHaveBeenCalledTimes(2);
120 | expect(facts.f1).toHaveBeenCalledWith({ a: 1 });
121 | expect(facts.f1).toHaveBeenCalledWith({ a: 2 });
122 | });
123 |
124 | it('should deep equal memoize facts', async () => {
125 | const facts = { f1: jest.fn() };
126 | const rules = {
127 | rule1: {
128 | when: [
129 | { f1: { params: { a: [1, 1] }, is: {} } },
130 | { f1: { params: { a: [1, 1] }, is: {} } },
131 | { f1: { params: { a: [1, 2] }, is: {} } },
132 | ],
133 | },
134 | };
135 |
136 | const memEngine = createRulesEngine(createAjvValidator(), {
137 | memoizer: _.isEqual,
138 | facts,
139 | rules,
140 | });
141 |
142 | const regularEngine = createRulesEngine(createAjvValidator(), {
143 | facts,
144 | rules,
145 | });
146 |
147 | await regularEngine.run();
148 | expect(facts.f1).toHaveBeenCalledTimes(3);
149 | expect(facts.f1).toHaveBeenCalledWith({ a: [1, 1] });
150 | expect(facts.f1).toHaveBeenCalledWith({ a: [1, 1] });
151 | expect(facts.f1).toHaveBeenCalledWith({ a: [1, 2] });
152 |
153 | facts.f1.mockClear();
154 |
155 | await memEngine.run();
156 | expect(facts.f1).toHaveBeenCalledTimes(2);
157 | expect(facts.f1).toHaveBeenCalledWith({ a: [1, 1] });
158 | expect(facts.f1).toHaveBeenCalledWith({ a: [1, 2] });
159 | });
160 |
161 | it('should access a property via path', async () => {
162 | const rules = {
163 | salutation: {
164 | when: [
165 | {
166 | user: { path: 'firstName', is: { type: 'string', pattern: '^J' } },
167 | },
168 | ],
169 | then: {
170 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
171 | },
172 | },
173 | };
174 | engine.setRules(rules);
175 | await engine.run({ user: { firstName: 'John' } });
176 | expect(log).toHaveBeenCalledWith({ message: 'Hi friend!' });
177 | log.mockClear();
178 | await engine.run({ user: { firstName: 'Bill' } });
179 | expect(log).not.toHaveBeenCalled();
180 | });
181 |
182 | it('should use a custom resolver', async () => {
183 | const thisEngine = createRulesEngine(validator, { resolver: get });
184 | const rules = {
185 | salutation: {
186 | when: [
187 | {
188 | user: { path: '/firstName', is: { type: 'string', pattern: '^J' } },
189 | },
190 | ],
191 | then: {
192 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
193 | },
194 | },
195 | };
196 | thisEngine.setRules(rules);
197 | thisEngine.setActions({ log });
198 | await thisEngine.run({ user: { firstName: 'John' } });
199 | expect(log).toHaveBeenCalledWith({ message: 'Hi friend!' });
200 | log.mockClear();
201 | await thisEngine.run({ user: { firstName: 'Bill' } });
202 | expect(log).not.toHaveBeenCalled();
203 | });
204 |
205 | it('should execute a rule asynchronously', async () => {
206 | const lookupUser = jest.fn(async () => ({ firstName: 'John' }));
207 | const rules = {
208 | salutation: {
209 | when: [
210 | {
211 | lookupUser: {
212 | is: {
213 | type: 'object',
214 | properties: { firstName: { type: 'string', pattern: '^J' } },
215 | },
216 | },
217 | },
218 | ],
219 | then: {
220 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
221 | },
222 | },
223 | };
224 | engine.setFacts({ lookupUser });
225 | engine.setRules(rules);
226 | await engine.run();
227 | expect(lookupUser).toHaveBeenCalled();
228 | expect(log).toHaveBeenCalledWith({ message: 'Hi friend!' });
229 | });
230 |
231 | it('should interpolate results', async () => {
232 | const rules = {
233 | salutation: {
234 | when: [
235 | {
236 | user: { path: 'firstName', is: { type: 'string', pattern: '^F' } },
237 | },
238 | ],
239 | then: {
240 | actions: [
241 | {
242 | type: 'log',
243 | params: {
244 | value: '{{results[0].user.value}}',
245 | resolved: '{{results[0].user.resolved}}',
246 | message: 'Hi {{results[0].user.resolved}}!',
247 | },
248 | },
249 | ],
250 | },
251 | },
252 | };
253 |
254 | const user = {
255 | firstName: 'Freddie',
256 | lastName: 'Mercury',
257 | };
258 | engine.setRules(rules);
259 | await engine.run({ user });
260 | expect(log).toHaveBeenCalledWith({
261 | value: user,
262 | resolved: user.firstName,
263 | message: `Hi ${user.firstName}!`,
264 | });
265 | });
266 |
267 | it('should interpolate results with a named fact map', async () => {
268 | const rules = {
269 | salutation: {
270 | when: {
271 | checkName: {
272 | user: { path: 'firstName', is: { type: 'string', pattern: '^F' } },
273 | },
274 | },
275 | then: {
276 | actions: [
277 | {
278 | type: 'log',
279 | params: {
280 | value: '{{results.checkName.user.value}}',
281 | resolved: '{{results.checkName.user.resolved}}',
282 | message: 'Hi {{results.checkName.user.resolved}}!',
283 | },
284 | },
285 | ],
286 | },
287 | },
288 | };
289 |
290 | const user = {
291 | firstName: 'Freddie',
292 | lastName: 'Mercury',
293 | };
294 | engine.setRules(rules);
295 | await engine.run({ user });
296 | expect(log).toHaveBeenCalledWith({
297 | value: user,
298 | resolved: user.firstName,
299 | message: `Hi ${user.firstName}!`,
300 | });
301 | });
302 |
303 | it('should interpolate using a custom pattern', async () => {
304 | const thisEngine = createRulesEngine(validator, { pattern: /\$(.+?)\$/g });
305 | const rules = {
306 | salutation: {
307 | when: [
308 | {
309 | user: { path: 'firstName', is: { type: 'string', pattern: '^F' } },
310 | },
311 | ],
312 | then: {
313 | actions: [
314 | {
315 | type: 'log',
316 | params: {
317 | value: '$results[0].user.value$',
318 | resolved: '$results[0].user.resolved$',
319 | message: 'Hi $results[0].user.resolved$!',
320 | },
321 | },
322 | ],
323 | },
324 | },
325 | };
326 |
327 | const user = {
328 | firstName: 'Freddie',
329 | lastName: 'Mercury',
330 | };
331 | thisEngine.setRules(rules);
332 | thisEngine.setActions({ log });
333 | await thisEngine.run({ user });
334 | expect(log).toHaveBeenCalledWith({
335 | value: user,
336 | resolved: user.firstName,
337 | message: `Hi ${user.firstName}!`,
338 | });
339 | });
340 |
341 | it('should interpolate using a custom resolver', async () => {
342 | const thisEngine = createRulesEngine(validator, { resolver: get });
343 | const rules = {
344 | salutation: {
345 | when: [
346 | {
347 | user: { path: '/firstName', is: { type: 'string', pattern: '^F' } },
348 | },
349 | ],
350 | then: {
351 | actions: [
352 | {
353 | type: 'log',
354 | params: {
355 | value: '{{/results/0/user/value}}',
356 | resolved: '{{/results/0/user/resolved}}',
357 | message: 'Hi {{/results/0/user/resolved}}!',
358 | },
359 | },
360 | ],
361 | },
362 | },
363 | };
364 |
365 | const user = {
366 | firstName: 'Freddie',
367 | lastName: 'Mercury',
368 | };
369 | thisEngine.setRules(rules);
370 | thisEngine.setActions({ log });
371 | await thisEngine.run({ user });
372 | expect(log).toHaveBeenCalledWith({
373 | value: user,
374 | resolved: user.firstName,
375 | message: `Hi ${user.firstName}!`,
376 | });
377 | });
378 | });
379 |
--------------------------------------------------------------------------------
/test/events.test.ts:
--------------------------------------------------------------------------------
1 | import createRulesEngine, { RulesEngine } from '../src';
2 | import { createAjvValidator } from './validators';
3 |
4 | describe('events', () => {
5 | let engine: RulesEngine;
6 | let log: jest.Mock;
7 | let call: jest.Mock;
8 |
9 | beforeEach(() => {
10 | log = jest.fn();
11 | call = jest.fn();
12 | engine = createRulesEngine(createAjvValidator(), {
13 | actions: { log, call },
14 | });
15 | });
16 |
17 | it('should unsubscribe', async () => {
18 | const rules = {
19 | salutation: {
20 | when: {
21 | myFacts: {
22 | firstName: { is: { type: 'string', pattern: '^J' } },
23 | },
24 | },
25 | then: {
26 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
27 | },
28 | otherwise: {
29 | actions: [{ type: 'call', params: { message: 'Who are you?' } }],
30 | },
31 | },
32 | };
33 | engine.setRules(rules);
34 | const subscriber = jest.fn();
35 | const unsub = engine.on('debug', subscriber);
36 | await engine.run({ firstName: 'John' });
37 | expect(subscriber).toHaveBeenCalled();
38 | unsub();
39 | subscriber.mockClear();
40 | await engine.run({ firstName: 'John' });
41 | expect(subscriber).not.toHaveBeenCalled();
42 | });
43 |
44 | it('should subscribe to debug events', async () => {
45 | const rules = {
46 | salutation: {
47 | when: {
48 | myFacts: {
49 | firstName: { is: { type: 'string', pattern: '^J' } },
50 | },
51 | },
52 | then: {
53 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
54 | },
55 | otherwise: {
56 | actions: [{ type: 'call', params: { message: 'Who are you?' } }],
57 | },
58 | },
59 | };
60 | engine.setRules(rules);
61 | const subscriber = jest.fn();
62 | const context = { firstName: 'John' };
63 | engine.on('debug', subscriber);
64 | await engine.run(context);
65 |
66 | expect(subscriber).toHaveBeenNthCalledWith(
67 | 1,
68 | expect.objectContaining({
69 | type: 'STARTING_RULE',
70 | rule: 'salutation',
71 | interpolated: rules.salutation.when,
72 | context,
73 | }),
74 | );
75 |
76 | expect(subscriber).toHaveBeenNthCalledWith(
77 | 2,
78 | expect.objectContaining({
79 | type: 'STARTING_FACT_MAP',
80 | rule: 'salutation',
81 | mapId: 'myFacts',
82 | factMap: rules.salutation.when.myFacts,
83 | }),
84 | );
85 |
86 | expect(subscriber).toHaveBeenNthCalledWith(
87 | 3,
88 | expect.objectContaining({
89 | type: 'STARTING_FACT',
90 | rule: 'salutation',
91 | mapId: 'myFacts',
92 | factName: 'firstName',
93 | }),
94 | );
95 |
96 | expect(subscriber).toHaveBeenNthCalledWith(
97 | 4,
98 | expect.objectContaining({
99 | type: 'EXECUTED_FACT',
100 | rule: 'salutation',
101 | mapId: 'myFacts',
102 | path: undefined,
103 | factName: 'firstName',
104 | value: context.firstName,
105 | resolved: context.firstName,
106 | }),
107 | );
108 |
109 | expect(subscriber).toHaveBeenNthCalledWith(
110 | 5,
111 | expect.objectContaining({
112 | type: 'EVALUATED_FACT',
113 | rule: 'salutation',
114 | mapId: 'myFacts',
115 | path: undefined,
116 | factName: 'firstName',
117 | value: 'John',
118 | resolved: 'John',
119 | is: { type: 'string', pattern: '^J' },
120 | result: { result: true },
121 | }),
122 | );
123 |
124 | expect(subscriber).toHaveBeenNthCalledWith(
125 | 6,
126 | expect.objectContaining({
127 | type: 'FINISHED_FACT_MAP',
128 | rule: 'salutation',
129 | mapId: 'myFacts',
130 | results: {
131 | firstName: { result: true, value: 'John', resolved: 'John' },
132 | },
133 | passed: true,
134 | error: false,
135 | }),
136 | );
137 |
138 | expect(subscriber).toHaveBeenNthCalledWith(
139 | 7,
140 | expect.objectContaining({
141 | type: 'FINISHED_RULE',
142 | rule: 'salutation',
143 | interpolated: rules.salutation.when,
144 | context,
145 | result: {
146 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
147 | results: {
148 | myFacts: expect.objectContaining({
149 | firstName: {
150 | result: true,
151 | value: 'John',
152 | resolved: 'John',
153 | },
154 | }),
155 | },
156 | },
157 | }),
158 | );
159 | });
160 |
161 | it('should emit a non-blocking ActionExecutionError if an action throws an error', async () => {
162 | const rules = {
163 | salutation: {
164 | when: {
165 | myFacts: { firstName: { is: { type: 'string', pattern: '^J' } } },
166 | },
167 | then: {
168 | actions: [
169 | { type: 'log', params: { message: 'Hi friend!' } },
170 | { type: 'call', params: { message: 'called anyway' } },
171 | ],
172 | },
173 | },
174 | };
175 | engine.setRules(rules);
176 | const error = new Error('nad');
177 | log.mockImplementationOnce(() => {
178 | throw error;
179 | });
180 | const spy = jest.fn();
181 | engine.on('error', spy);
182 | await engine.run({ firstName: 'John' });
183 | expect(spy).toHaveBeenCalledWith({
184 | type: 'ActionExecutionError',
185 | action: 'log',
186 | params: { message: 'Hi friend!' },
187 | rule: 'salutation',
188 | error,
189 | });
190 | expect(call).toHaveBeenCalledWith({ message: 'called anyway' });
191 | });
192 |
193 | it('should emit a non-blocking ActionExecutionError if an action has not been supplied', async () => {
194 | const rules = {
195 | salutation: {
196 | when: {
197 | myFacts: { firstName: { is: { type: 'string', pattern: '^J' } } },
198 | },
199 | then: {
200 | actions: [
201 | { type: 'nonAction', params: { message: 'Hi friend!' } },
202 | { type: 'call', params: { message: 'called anyway' } },
203 | ],
204 | },
205 | },
206 | };
207 | engine.setRules(rules);
208 | const spy = jest.fn();
209 | engine.on('error', spy);
210 | await engine.run({ firstName: 'John' });
211 | expect(spy).toHaveBeenCalledWith({
212 | type: 'ActionExecutionError',
213 | action: 'nonAction',
214 | params: { message: 'Hi friend!' },
215 | rule: 'salutation',
216 | error: expect.objectContaining({
217 | message: 'No action found for nonAction',
218 | }),
219 | });
220 | expect(call).toHaveBeenCalledWith({ message: 'called anyway' });
221 | });
222 |
223 | it('should emit a FactExecutionError and continue', async () => {
224 | const error = new Error('bad');
225 | const context = {
226 | fromContext: {
227 | firstName: 'fred',
228 | },
229 | myFact: jest.fn(() => {
230 | throw error;
231 | }),
232 | };
233 |
234 | engine.setRules({
235 | salutation: {
236 | when: [
237 | {
238 | myFact: {
239 | params: '{{fromContext}}',
240 | is: { type: 'string', pattern: '^J' },
241 | },
242 | },
243 | {
244 | fromContext: {
245 | path: 'firstName',
246 | is: { type: 'string', const: 'fred' },
247 | },
248 | },
249 | ],
250 | then: {
251 | actions: [{ type: 'call', params: { message: 'called anyway' } }],
252 | },
253 | },
254 | });
255 | const spy = jest.fn();
256 | engine.on('error', spy);
257 | await engine.run(context);
258 | expect(spy).toHaveBeenCalledWith({
259 | type: 'FactExecutionError',
260 | factName: 'myFact',
261 | mapId: 0,
262 | params: context.fromContext,
263 | rule: 'salutation',
264 | error,
265 | });
266 | expect(call).toHaveBeenCalledWith({ message: 'called anyway' });
267 | });
268 |
269 | it('should emit a FactExecutionError not call any actions', async () => {
270 | const context = {
271 | fromContext: {
272 | firstName: 'fred',
273 | },
274 | myFact: jest.fn(() => {
275 | throw new Error('bad');
276 | }),
277 | };
278 |
279 | const spy = jest.fn();
280 | engine.on('error', spy);
281 | engine.setRules({
282 | salutation: {
283 | when: [
284 | {
285 | myFact: {
286 | params: '{{fromContext}}',
287 | is: { type: 'string', pattern: '^J' },
288 | },
289 | },
290 | ],
291 | then: {
292 | actions: [{ type: 'call', params: { message: 'called then' } }],
293 | },
294 | otherwise: {
295 | actions: [{ type: 'call', params: { message: 'called otherwise' } }],
296 | },
297 | },
298 | });
299 | await engine.run(context);
300 | const error = new Error('bad');
301 | expect(spy).toHaveBeenCalledWith({
302 | type: 'FactExecutionError',
303 | factName: 'myFact',
304 | mapId: 0,
305 | params: context.fromContext,
306 | rule: 'salutation',
307 | error,
308 | });
309 | expect(call).not.toHaveBeenCalled();
310 | });
311 |
312 | it('should emit a FactEvaluationError', async () => {
313 | const error = new Error('bad');
314 | const thisEngine = createRulesEngine(
315 | jest.fn(() => {
316 | throw error;
317 | }),
318 | );
319 | const rules = {
320 | salutation: {
321 | when: {
322 | myFacts: {
323 | user: { path: 'firstName', is: { type: 'string', pattern: '^J' } },
324 | },
325 | },
326 | then: {
327 | actions: [{ type: 'log', params: { message: 'Hi friend!' } }],
328 | },
329 | otherwise: {
330 | actions: [{ type: 'call', params: { message: 'Who are you?' } }],
331 | },
332 | },
333 | };
334 | thisEngine.setRules(rules);
335 | const spy = jest.fn();
336 | const context = { user: { firstName: 'John' } };
337 | thisEngine.on('error', spy);
338 | await thisEngine.run(context);
339 | expect(spy).toHaveBeenCalledWith({
340 | type: 'FactEvaluationError',
341 | error,
342 | mapId: 'myFacts',
343 | rule: 'salutation',
344 | factName: 'user',
345 | path: 'firstName',
346 | is: { type: 'string', pattern: '^J' },
347 | resolved: 'John',
348 | value: { firstName: 'John' },
349 | });
350 | });
351 | });
352 |
--------------------------------------------------------------------------------
/test/facts.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch';
2 |
3 | export const weather = async ({ q, appId, units }) => {
4 | const url = `https://api.openweathermap.org/data/2.5/weather/?q=${q}&units=${units}&appid=${appId}`;
5 | return (await fetch(url)).json();
6 | };
7 |
8 | export const forecast = async ({ appId, coord, units }) => {
9 | const { lat, lon } = coord;
10 | const url = `https://api.openweathermap.org/data/2.5/onecall?lat=${lat}&lon=${lon}&units=${units}&appid=${appId}&exclude=hourly,minutely`;
11 | return (await fetch(url)).json();
12 | };
13 |
--------------------------------------------------------------------------------
/test/fixtures.ts:
--------------------------------------------------------------------------------
1 | export const weather = {
2 | coord: {
3 | lon: -63.5724,
4 | lat: 44.6453,
5 | },
6 | weather: [
7 | {
8 | id: 800,
9 | main: 'Clear',
10 | description: 'clear sky',
11 | icon: '01n',
12 | },
13 | ],
14 | base: 'stations',
15 | main: {
16 | temp: 14.84,
17 | feels_like: 14.31,
18 | temp_min: 14.44,
19 | temp_max: 15.03,
20 | pressure: 1008,
21 | humidity: 74,
22 | },
23 | visibility: 10000,
24 | wind: {
25 | speed: 2.57,
26 | deg: 260,
27 | },
28 | clouds: {
29 | all: 8,
30 | },
31 | dt: 1630719841,
32 | sys: {
33 | type: 1,
34 | id: 682,
35 | country: 'CA',
36 | sunrise: 1630661970,
37 | sunset: 1630709284,
38 | },
39 | timezone: -10800,
40 | id: 6324729,
41 | name: 'Halifax',
42 | cod: 200,
43 | };
44 |
45 | export const forecast = {
46 | lat: 44.6453,
47 | lon: -63.5724,
48 | timezone: 'America/Halifax',
49 | timezone_offset: -10800,
50 | current: {
51 | dt: 1630720129,
52 | sunrise: 1630661970,
53 | sunset: 1630709284,
54 | temp: 14.84,
55 | feels_like: 14.31,
56 | pressure: 1008,
57 | humidity: 74,
58 | dew_point: 10.25,
59 | uvi: 0,
60 | clouds: 8,
61 | visibility: 10000,
62 | wind_speed: 2.57,
63 | wind_deg: 260,
64 | weather: [
65 | {
66 | id: 800,
67 | main: 'Clear',
68 | description: 'clear sky',
69 | icon: '01n',
70 | },
71 | ],
72 | },
73 | daily: [
74 | {
75 | dt: 1630684800,
76 | sunrise: 1630661970,
77 | sunset: 1630709284,
78 | moonrise: 1630646640,
79 | moonset: 1630704480,
80 | moon_phase: 0.88,
81 | temp: {
82 | day: 17.37,
83 | min: 14.16,
84 | max: 18.4,
85 | night: 14.84,
86 | eve: 16.29,
87 | morn: 14.42,
88 | },
89 | feels_like: {
90 | day: 17.04,
91 | night: 14.31,
92 | eve: 15.93,
93 | morn: 14.19,
94 | },
95 | pressure: 1008,
96 | humidity: 72,
97 | dew_point: 11.97,
98 | wind_speed: 9.28,
99 | wind_deg: 222,
100 | wind_gust: 14.67,
101 | weather: [
102 | {
103 | id: 500,
104 | main: 'Rain',
105 | description: 'light rain',
106 | icon: '10d',
107 | },
108 | ],
109 | clouds: 100,
110 | pop: 0.45,
111 | rain: 0.13,
112 | uvi: 1.63,
113 | },
114 | {
115 | dt: 1630771200,
116 | sunrise: 1630748440,
117 | sunset: 1630795574,
118 | moonrise: 1630736940,
119 | moonset: 1630793040,
120 | moon_phase: 0.92,
121 | temp: {
122 | day: 15.84,
123 | min: 11.04,
124 | max: 16.12,
125 | night: 13,
126 | eve: 13.95,
127 | morn: 11.04,
128 | },
129 | feels_like: {
130 | day: 14.89,
131 | night: 12.29,
132 | eve: 13.31,
133 | morn: 10.31,
134 | },
135 | pressure: 1007,
136 | humidity: 54,
137 | dew_point: 6.27,
138 | wind_speed: 6.56,
139 | wind_deg: 305,
140 | wind_gust: 13.02,
141 | weather: [
142 | {
143 | id: 804,
144 | main: 'Clouds',
145 | description: 'overcast clouds',
146 | icon: '04d',
147 | },
148 | ],
149 | clouds: 97,
150 | pop: 0.16,
151 | uvi: 3.55,
152 | },
153 | {
154 | dt: 1630857600,
155 | sunrise: 1630834910,
156 | sunset: 1630881864,
157 | moonrise: 1630827540,
158 | moonset: 1630881240,
159 | moon_phase: 0.95,
160 | temp: {
161 | day: 18.54,
162 | min: 9.31,
163 | max: 19.74,
164 | night: 14.72,
165 | eve: 16.63,
166 | morn: 9.31,
167 | },
168 | feels_like: {
169 | day: 17.83,
170 | night: 14.7,
171 | eve: 16.25,
172 | morn: 7.53,
173 | },
174 | pressure: 1015,
175 | humidity: 53,
176 | dew_point: 8.44,
177 | wind_speed: 6.52,
178 | wind_deg: 310,
179 | wind_gust: 12.71,
180 | weather: [
181 | {
182 | id: 803,
183 | main: 'Clouds',
184 | description: 'broken clouds',
185 | icon: '04d',
186 | },
187 | ],
188 | clouds: 58,
189 | pop: 0.09,
190 | uvi: 6.21,
191 | },
192 | {
193 | dt: 1630944000,
194 | sunrise: 1630921380,
195 | sunset: 1630968153,
196 | moonrise: 1630918320,
197 | moonset: 1630969200,
198 | moon_phase: 0,
199 | temp: {
200 | day: 21.52,
201 | min: 15.09,
202 | max: 21.52,
203 | night: 18.34,
204 | eve: 17.55,
205 | morn: 17.61,
206 | },
207 | feels_like: {
208 | day: 21.58,
209 | night: 18.76,
210 | eve: 17.87,
211 | morn: 17.65,
212 | },
213 | pressure: 1012,
214 | humidity: 71,
215 | dew_point: 15.71,
216 | wind_speed: 8.64,
217 | wind_deg: 197,
218 | wind_gust: 14.39,
219 | weather: [
220 | {
221 | id: 500,
222 | main: 'Rain',
223 | description: 'light rain',
224 | icon: '10d',
225 | },
226 | ],
227 | clouds: 100,
228 | pop: 1,
229 | rain: 3.62,
230 | uvi: 1.62,
231 | },
232 | {
233 | dt: 1631030400,
234 | sunrise: 1631007850,
235 | sunset: 1631054441,
236 | moonrise: 1631009100,
237 | moonset: 1631057040,
238 | moon_phase: 0.02,
239 | temp: {
240 | day: 21.18,
241 | min: 15.57,
242 | max: 22.71,
243 | night: 15.57,
244 | eve: 21.09,
245 | morn: 16.35,
246 | },
247 | feels_like: {
248 | day: 20.76,
249 | night: 15.25,
250 | eve: 20.69,
251 | morn: 16.57,
252 | },
253 | pressure: 1009,
254 | humidity: 54,
255 | dew_point: 11.03,
256 | wind_speed: 6.01,
257 | wind_deg: 259,
258 | wind_gust: 10.3,
259 | weather: [
260 | {
261 | id: 500,
262 | main: 'Rain',
263 | description: 'light rain',
264 | icon: '10d',
265 | },
266 | ],
267 | clouds: 14,
268 | pop: 0.85,
269 | rain: 1.26,
270 | uvi: 5.85,
271 | },
272 | {
273 | dt: 1631116800,
274 | sunrise: 1631094319,
275 | sunset: 1631140729,
276 | moonrise: 1631099940,
277 | moonset: 1631144820,
278 | moon_phase: 0.06,
279 | temp: {
280 | day: 21.58,
281 | min: 12.87,
282 | max: 21.99,
283 | night: 16.42,
284 | eve: 19.89,
285 | morn: 12.87,
286 | },
287 | feels_like: {
288 | day: 21.2,
289 | night: 16.39,
290 | eve: 19.63,
291 | morn: 12.43,
292 | },
293 | pressure: 1017,
294 | humidity: 54,
295 | dew_point: 11.21,
296 | wind_speed: 4.91,
297 | wind_deg: 203,
298 | wind_gust: 8.67,
299 | weather: [
300 | {
301 | id: 800,
302 | main: 'Clear',
303 | description: 'clear sky',
304 | icon: '01d',
305 | },
306 | ],
307 | clouds: 6,
308 | pop: 0,
309 | uvi: 1.08,
310 | },
311 | {
312 | dt: 1631203200,
313 | sunrise: 1631180789,
314 | sunset: 1631227017,
315 | moonrise: 1631190900,
316 | moonset: 1631232600,
317 | moon_phase: 0.09,
318 | temp: {
319 | day: 23.14,
320 | min: 16.9,
321 | max: 23.59,
322 | night: 19.8,
323 | eve: 20.33,
324 | morn: 18.52,
325 | },
326 | feels_like: {
327 | day: 23.34,
328 | night: 20.42,
329 | eve: 21,
330 | morn: 18.73,
331 | },
332 | pressure: 1014,
333 | humidity: 70,
334 | dew_point: 16.74,
335 | wind_speed: 6.33,
336 | wind_deg: 168,
337 | wind_gust: 13.94,
338 | weather: [
339 | {
340 | id: 500,
341 | main: 'Rain',
342 | description: 'light rain',
343 | icon: '10d',
344 | },
345 | ],
346 | clouds: 38,
347 | pop: 1,
348 | rain: 3.4,
349 | uvi: 2,
350 | },
351 | {
352 | dt: 1631289600,
353 | sunrise: 1631267259,
354 | sunset: 1631313304,
355 | moonrise: 1631281920,
356 | moonset: 1631320560,
357 | moon_phase: 0.13,
358 | temp: {
359 | day: 19.02,
360 | min: 16.59,
361 | max: 19.57,
362 | night: 16.59,
363 | eve: 19.22,
364 | morn: 19.49,
365 | },
366 | feels_like: {
367 | day: 19.51,
368 | night: 16.52,
369 | eve: 19.42,
370 | morn: 20.08,
371 | },
372 | pressure: 1005,
373 | humidity: 97,
374 | dew_point: 18.31,
375 | wind_speed: 5.6,
376 | wind_deg: 331,
377 | wind_gust: 9.75,
378 | weather: [
379 | {
380 | id: 500,
381 | main: 'Rain',
382 | description: 'light rain',
383 | icon: '10d',
384 | },
385 | ],
386 | clouds: 100,
387 | pop: 1,
388 | rain: 8.06,
389 | uvi: 2,
390 | },
391 | ],
392 | };
393 |
--------------------------------------------------------------------------------
/test/memo.test.ts:
--------------------------------------------------------------------------------
1 | import { memo, memoRecord } from '../src/memo';
2 |
3 | describe('memo', () => {
4 | it('should memoize', () => {
5 | const subject = jest.fn();
6 |
7 | const memoed = memo(subject);
8 | memoed('a');
9 | expect(subject).toHaveBeenCalledWith('a');
10 | expect(subject).toHaveBeenCalledTimes(1);
11 | subject.mockClear();
12 | memoed('a');
13 | expect(subject).not.toHaveBeenCalled();
14 | memoed('b');
15 | expect(subject).toHaveBeenCalledWith('b');
16 | expect(subject).toHaveBeenCalledTimes(1);
17 | });
18 |
19 | it('should memoize a record', () => {
20 | const subject = jest.fn();
21 | const memoed = memoRecord({ subject });
22 | memoed.subject('a');
23 | expect(subject).toHaveBeenCalledWith('a');
24 | expect(subject).toHaveBeenCalledTimes(1);
25 | subject.mockClear();
26 | memoed.subject('a');
27 | expect(subject).not.toHaveBeenCalled();
28 | memoed.subject('b');
29 | expect(subject).toHaveBeenCalledWith('b');
30 | expect(subject).toHaveBeenCalledTimes(1);
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/test/rules.ts:
--------------------------------------------------------------------------------
1 | export const weatherRule = {
2 | when: [
3 | {
4 | weather: {
5 | name: 'weather1',
6 | params: {
7 | q: '{{query}}',
8 | appId: '{{apiKey}}',
9 | units: '{{units}}',
10 | },
11 | is: { type: 'object' },
12 | },
13 | },
14 | ],
15 | then: {
16 | when: [
17 | {
18 | forecast: {
19 | name: 'forecast',
20 | params: {
21 | coord: '{{results.weather1.value.coord}}',
22 | appId: '{{apiKey}}',
23 | units: '{{units}}',
24 | },
25 | path: 'daily',
26 | is: {
27 | type: 'array',
28 | contains: {
29 | type: 'object',
30 | properties: {
31 | temp: {
32 | type: 'object',
33 | properties: {
34 | max: {
35 | type: 'number',
36 | minimum: '{{minimumWarmTemp}}',
37 | },
38 | },
39 | },
40 | },
41 | },
42 | minContains: '{{minimumWarmDays}}',
43 | },
44 | },
45 | },
46 | ],
47 | then: {
48 | actions: [
49 | {
50 | type: 'log',
51 | params: {
52 | weather: '{{results.weather1.value}}',
53 | forecast: '{{results.forecast.resolved}}',
54 | },
55 | },
56 | ],
57 | },
58 | otherwise: {
59 | actions: [
60 | {
61 | type: 'log',
62 | params: {
63 | forecast: '{{results.forecast.value.daily}}',
64 | },
65 | },
66 | ],
67 | },
68 | },
69 | };
70 |
71 | export const salutation = {
72 | when: [
73 | {
74 | firstName: {
75 | is: {
76 | type: 'string',
77 | const: 'Adam',
78 | },
79 | },
80 | },
81 | {
82 | firstName: {
83 | is: {
84 | type: 'string',
85 | const: 'Janet',
86 | },
87 | },
88 | },
89 | ],
90 | then: {
91 | actions: [
92 | {
93 | type: 'salute',
94 | params: {
95 | message: 'Hi there {{firstName}}!',
96 | },
97 | },
98 | ],
99 | },
100 | otherwise: {
101 | actions: {
102 | type: 'shrugoff',
103 | params: { name: '{{firstName}}' },
104 | },
105 | },
106 | };
107 |
--------------------------------------------------------------------------------
/test/validators.ts:
--------------------------------------------------------------------------------
1 | import Ajv2019 from 'ajv/dist/2019';
2 |
3 | export const createAjvValidator = () => {
4 | const ajv = new Ajv2019();
5 | return async (object: any, schema: any) => {
6 | const validate = ajv.compile(schema);
7 | return { result: validate(object) };
8 | };
9 | };
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Enable incremental compilation */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | "lib": ["es2019"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 |
26 | /* Modules */
27 | "module": "commonjs", /* Specify what module code is generated. */
28 | "rootDir": "src", /* Specify the root folder within your source files. */
29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
36 | // "resolveJsonModule": true, /* Enable importing .json files */
37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */
38 |
39 | /* JavaScript Support */
40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
43 |
44 | /* Emit */
45 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
50 | "outDir": "./build", /* Specify an output folder for all emitted files. */
51 | // "removeComments": true, /* Disable emitting comments. */
52 | // "noEmit": true, /* Disable emitting files from a compilation. */
53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
61 | // "newLine": "crlf", /* Set the newline character for emitting files. */
62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
67 |
68 | /* Interop Constraints */
69 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
70 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
71 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
72 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
73 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
74 |
75 | /* Type Checking */
76 | "strict": true, /* Enable all strict type-checking options. */
77 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
78 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
79 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
80 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
81 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
82 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
83 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
84 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
85 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
86 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
87 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
88 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
89 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
90 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
91 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
92 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
93 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
94 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
95 |
96 | /* Completeness */
97 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
98 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
99 | },
100 | "exclude": ["./test/**/*","build"]
101 | }
102 |
--------------------------------------------------------------------------------