Imagine you need to write rules to modify some data based on a growing set of business logic. You could write it in vanilla JavaScript but chances are you quickly end up with something like this:
if(businessCondition1) {
if(businessCondition2) {
if(businessCondition3) {
if(businessCondition4) {
if(businessCondition5) {
// do something
// KA-ME-HA-ME-HA of death
}
}
}
}
}
This is a small zero-dependency library created to write rule logic
to manipulate data that's much more maintainable, readable, composable, reusable
and testable than writing plain if/else
logic.
The basic structure of a rule is the following:
const myRule = {
// a matcher is a function to check whether the action should be run
matcher: (facts, previousValue) => true || false,
// if the matcher returns true, run the action getting facts and the previous rule's
// value. Return a new value which will be passed on to the next rule.
action: (facts, url) => {
return { value: 'something new' } // return new data
}
}
What are facts? Are data needed by your rules. facts
stay the same throughout
the entire evaluation of the rules. Examples: Config data, the current date or
data fetched from an API.
What's the initialValue? In the end your rules are there to evaluate logic
and produce a value. The initialValue
is what gets returned if no
rule is run because the matcher
returns false. A default value if you will,
similar to the last argument of Array.prototype.reduce()
.
Using rules always involves three steps:
- Defining matchers: To check whether certain rules should be run or not.
- Defining rules: Rules use
matcher
s to see if theiraction
should be run. The action returns a new value which is passed on to the next rule. If the matcher doesn't match, thepreviousValue
is passed on instead. Simple rules can be combined into more complex rules using function likeapplyFirst
orapplyAll
- Evaluate your rules: Use the
run
ordetailedRun
function to execute your rules for a givenfacts
object andinitialValue
. The return value will be the value being producing by the rules.
Let's start with a very simple example. Your product owner wants you to create special offers for fruit, which follows certain rules. The rules are based on seasonality, what's in stock and how tropical the fruits are. In the end, the logic should produce and an array of special offers.
Hint: Rules are meant to be immutable so create new ones instead of trying to change
existing ones. Running the rules is always synchronous, so no async code in
the rules. If you need to fetch data asynchronously, fetch it beforehand and pass
it to run
/detailedRun
as facts
or initialValue
.
import { applyAll, one, run } from '@burdaforward/composable-rules';
const fruitsInStock = [
{ type: 'apple', price: 1 },
{ type: 'pineapple', price: 3 },
{ type: 'melon', price: 4 },
{ type: 'coconut', price: 2 },
{ type: 'orange', price: 1 },
];
// 1. MATCHERS: define matcher functions
const isApple = fruit => fruit.type === 'apple';
const isPineapple = fruit => fruit.type === 'pineapple';
const isCoconut = fruit => fruit.type === 'coconut';
const hasTropicalFruits = (facts, specialOffers) =>
facts.fruitsInStock.some(isPineapple) && facts.fruitsInStock.some(isCoconut);
const moreThan100Apples = (facts, specialOffers) =>
facts.fruitsInStock.filter(isApple).length > 100;
const isJuly = (facts, specialOffers) => facts.currentDate.getMonth() === 6;
const isAugust = (facts, specialOffers) => facts.currentDate.getMonth() === 7;
// 2. RULES define your rules
// Rule 1: if there are more than 100 apples in stock, create a special offer selling 100 apples for 50$.
const discountApplesRule = {
matcher: moreThan100Apples,
action: (facts, specialOffers) => [
...specialOffers,
{ specialOffer: 'Get 100 apples now for only 50$!' },
],
};
// Rule 2: in july or august, raise prices for lemons (lemonade season)
const lemonadeRule = {
matcher: one([isJuly, isAugust]), // combine two matchers requiring one of the to be true
action: (facts, specialOffers) => [
...specialOffers,
{ specialOffer: 'Get your lemonade' },
],
};
// Rule 3: if melons, pineapples and coconuts are in stock, offer Pina Colada
const tropicalRule = {
matcher: hasTropicalFruits,
action: (facts, specialOffers) => [
...specialOffers,
{ specialOffer: 'Aloha Tropical Breeze! Get our Pina Colada now!' },
],
};
// combine all rules together
const fruitRule = applyAll([discountApplesRule, lemonadeRule, tropicalRule]);
// 3. run your rules like this:
const facts = { fruitsInStock, currentDate: new Date() };
const [error, specialOffers] = run(fruitRule, facts, []);
if (error) {
// handle error
}
console.log(specialOffers)
// logs [{ specialOffer: "Get your lemonade!" }, { specialOffer: "Aloha. Tropical triple!" }]
Even though this example was simple and involved fruits, you'll see that you can use this library for any sort of logic no matter what it is. Next up is a real-life example.
The following is an example of a URL rewrite engine, which uses this library to rewrite urls according to a given set of rules. Here's a small peek into how this can be done. A URL is passed into the rules and manipulated according to certain logic. Each manipulation can be implemented as a rule and rules can then be composed into more complex rules until a whole rewrite engine is built.
Note: To simplify url manipulation we use the nurl
library, which is easier
to use than NodeJS's url
module and has an immutable URL type.
Then define and run the rules like this
import nurl from 'nurl';
import { all, applyAll, applyFirst, run} from '@burdaforward/composable-rules';
// MATCHERS
const isChipHost = (facts, url) => url.hostname === 'chip.de';
const hasRewriteParam = (facts, url) => url.hasQueryParam('rewrite');
const myRule = {
// use `all` to combine functions requiring both matchers to return `true`
matcher: all([isChipHost, hasRewriteParam]),
// if matcher matches, rewrite to chip.de, `url` will be url object
// and contains modifications made so far by previous rules
action: (facts, url) => {
return url.setHostname('chip.de') // return new url object
}
}
// arbitrary data that is passed to the matchers and actions
const deeplink = nurl.parse('https://someurl.com/iphone8?param=value');
const facts = {
config: { ... },
url: deeplink
};
// combine rules, using `applyAll` will run all rules with passing matchers in order
// this only creates a new rule and doesn't run it yet
const combinedRule = applyAll([myRule, anotherRule]);
// run the rule on some data
const [error, manipulatedUrl] = run(
combinedRule,
facts,
deeplink // the initial url
);
// OR combine rules, checking for the first match and ignoring the rest
// this only creates a new rule and doesn't run the rule yet
const combinedRule = applyFirst([myRule, anotherRule]);
// run the rule on some data, will return the rewritten URL
const [error, manipulatedUrl] = run(
combinedRule,
facts,
deeplink
);
// BONUS: extra facts can be injected locally scoped to a rule like this:
const addSpecificFacts = (facts) => ({ ...facts, specificData: { a: 42 } })
const combinedRule = applyFirst([
injectFacts(addSpecificFacts, myRule), // this rule will have access to a transformed facts object
anotherRule
]);
Since composable-rules
executes some user-written functions on your behalf
there is a possibility of errors being thrown when rules are run.
So when running rules a tuple is always returned where the first value is an error
if anything was thrown or null
otherwise. You can check for errors like this
(try catch is not needed).
import { run, detailedRun } from '@burdaforward/composable-rules';
const [error, value] = run(rule, facts, initialValue);
if (error) {
// something crashed!
// handle the error
}
// Success!
// work with the value
You can also ignore the error like this if it is not relevant to you.
import { run, detailedRun } from '@burdaforward/composable-rules';
const [, value] = run(rule, facts, initialValue);
// work with the value
Since rules are just simple input/output logic, testing them is a breeze. At BurdaForward, a set of hundreds of rules has 100% tests coverage and testing is quick and easy.
test('this rule does what I want', () => {
const [, output] = run(myRule, facts, initialValue);
expect(output).toEqual(expectedOutput)
})
Make sure the package is installed.
npm i -S @burdaforward/composable-rules
Then import the package in your code depending on whether you use ES Modules, NodeJs require, or Browser scripts.
// import named exports or all as rules
import { all, run, applyFirst } from '@burdaforward/composable-rules';
import * as rules from '@burdaforward/composable-rules';
// import all as rules or destructure the exports
const rules = require('@burdaforward/composable-rules');
const { all, run, applyFirst } = require('@burdaforward/composable-rules');
<!-- Browsers that support ESM: unpkg link -->
<script type="module">
// import named exports or all as rules
import { all, run, applyFirst } from 'https://unpkg.com/@burdaforward/composable-rules@1.0.0/dist/index.modern.js';
import * as rules from 'https://unpkg.com/@burdaforward/composable-rules@1.0.0/dist/index.modern.js';
</script>
<!-- or for older browsers, access window.composableRules -->
<script src="https://unpkg.com/@burdaforward/composable-rules@1.0.0/dist/index.umd.js"></script>
Matchers
not
: negates a matcher.always
: A matcher that always matches.all
: Matcher combinator which takes an array of matchers. It is true when all passed matchers are true, false otherwise.one
: Matcher combinator which takes an array of matchers. It is only true when at least one of the passed matchers is true, false otherwise.
Combining and enhancing rules
injectFacts
: Takes a function and arule
. The function is passed thefacts
and can return a new transformed version offacts
(should copy instead of mutate). This is useful for passing, that are specific to one rule only.transformOutput
: Takes a function and arule
. If the rulesmatcher
matches, then the function is called with the output of the rulesaction
. This is useful for modifying(immutable!) an action's return value on a higher level.applyIf
: Checks if the passed matcher matches before running the rule. Takes amatcher
function and arule
and runs only that rule when the matcher matches in addition to the matcher the rule already has. The rule is then run and theaction
's value is returned.applyAll
: Takesrules
and combines them so that when run all supplied rules will be run in order for those whose matcher returnstrue
. It returns the modified value, in our case the modified URL.applyFirst
: Takesrules
and combines them so that when run, only the first supplied rule will be run whose matcher returnstrue
. It returns the modified value, in our case the modified URL.applyChain
: Takesrules
and combines them so that when run, only rules will be run as long as their matcher returnstrue
. As soon as a rule does not match it it stops. It returns the modified value, in our case the modified URL.
Running rules
run
: Takes arule
,facts
and an intialvalue
and runs the rule. It returns a tuple like[error, modifiedValue]
, in our case the modified URL. If no errors are throws theerror
will be null. If no rule matches the returned value is the original input value.detailedRun
: Likerun
but with a more detailed output and different default value. Takes arule
,facts
and an intialvalue
and runs the rule. It returns a tuple like[error, { value: <value>, foundMatch: bool }]
. The value will the modified value, in our case the modified URL or the original URL when no rule is matched.foundMatch
is a boolean indicating if any rule matched.
Please open an issue if you run into problems, have questions, or feature requests. For smaller fixes feel free to submit a pull request. For larger things please open an issue first, let's discuss it to make sure it fits the package so no work is wasted.