json-modifiable
TypeScript icon, indicating that this package has built-in type declarations

2.0.0 • Public • Published

json-modifiable

npm version Coverage Status Build Status Bundle Phobia

What is this?

An incredibly tiny and configurable rules engine for applying arbitrary modifications to a descriptor based on context. It's highly configurable, although you might find it easiest to write the rules using JSON standards -json pointer, json patch, and json schema

Why?

Serializable logic that can be easily stored in a database and shared amongst multiple components of your application.

Features

  • Highly configurable - define your own JSON structures. This doc encourages your rules to be written using json schema but the validator allows you to write them however you choose.
  • Configurable and highly performant interpolations (using uses interpolatable) to make highly reusable rules
  • Extremely lightweight (under 2kb minzipped)
  • Runs everywhere JavaScript does - Deno/Node/browsers

Installation

npm install json-modifiable
## or
yarn add json-modifiable

Or directly via the browser:

<script src="https://cdn.jsdelivr.net/npm/json-modifiable"></script>
<script>
  const descriptor = jsonModifiable.engine(...)
  
  // or see JSON Engine
  const descriptor = jsonModifiable.jsonEngine(...)
</script>

Concepts

Descriptor

type Descriptor = Record<string,unknown>

A descriptor is a plain-old JavaScript object (POJO) that should be "modified" - contain different properties/structures - in various contexts. The modifications are defined by rules.

{
    fieldId: 'lastName',
    path: 'user.lastName',
    label: 'Last Name',
    readOnly: false,
    placeholder: 'Enter Your First Name',
    type: 'text',
    hidden: true,
    validations: [],
}

Context

type Context = Record<string,unknown>

Context is also a plain object. The context is used by the validator to evaluate the conditions of rules

{
  formData: {
    firstName: 'Joe',
    lastName: 'Smith'
  }
}

Validator

type Validator = (schema: any, subject: any) => boolean;

A validator is the only dependency that must be user supplied. It accepts a condition and an subject to evaluate and it must synchronously return a boolean. Because of the extensive performance optimizations going on inside the engine to keep it blazing fast it's important to note the validator MUST BE A PURE FUNCTION

Here's a great one, and the one used in all our tests:

import { engine } from 'json-modifiable';
import Ajv from 'ajv';

const ajv = new Ajv();
const validator = ajv.validate.bind(ajv);

const modifiable = engine(myDescriptor, validator, rules);

You should be able to see that by supplying a different validator, you can write rules however you want, not just using JSON Schema.

Rules

export type Rule<Operation> = {
  when: Condition[];
  then?: Operation;
  otherwise?: Operation;
};

A rule is an object whose when property is an array of conditions and contains a then and/or otherwise clause. The validator will evaluate the conditions and, if any of them are true, will apply the then operation (if supplied) via the patch function. If none of them are true, the otherwise operation (if supplied) will be applied.

Condition

type Condition<Schema> = Record<string, Schema>;

A condition is a plain object whose keys are resolved to values (by means of the resolver function) and whose values are passed to the validator function with the resolved values.

The default resolver maps the keys of conditions to the values of context directly:

// context
{
  firstName: 'joe'
}

// condition
{
  firstName: {
    type: 'string',
    pattern: '^j'
  }
}

is given to the validator like this:

validator({ type: 'string', pattern: '^j'}, 'joe');

Operation

An operation is simply the value encoded in the then or otherwise of a rule. After the engine has run, the modified descriptor is computed by reducing all collected operations via the patch function in the order they were supplied in rules. They can be absolutely anything that the patch function can understand. The default patch function (literally Object.assign) expects the operation to be Partial<Descriptor> like this:

const descriptor = {
  fieldName: 'lastName',
  label: 'Last Name',
}

const rule = {
  when: {
    firstName: {
      type: 'string',
      minLength: 1
    }
  },
  then: {
    validations: ['required']
  }
}

// resultant descriptor when firstName is not empty
{
  fieldName: 'lastName',
  label: 'Last Name',
  validations: ['required']
}

Resolver

export type Resolver<Context> = (object: Context, path: string) => any;

A resolver resolves the keys of conditions to values. It is passed the key and the context being evaluated. The resultant value can be any value that will subsequently get passed to the validator. The default resolver simply maps the key of the condition to the key in context:

const resolver = (context, ket) => context[key]

But the resolver function can be anything you choose. Some other ideas are:

// context
{
  formData: {
    address: {
      street: '123 Fake Street'
    }
  }
}

// condition
{
  '/formData/address/street': {
    type: 'string'
  }
}

// for example:
import { get } from 'json-pointer';
const resolver = get;
// context
{
  formData: {
    address: {
      street: '123 Fake Street'
    }
  }
}

// condition
{
  'formData.address.street': {
    type: 'string'
  }
}

// for example:
import { get } from 'lodash';
const resolver = get;

Patch

The patch function does the work of applying the instructions encoded in the operations to the descriptor to end up with the final "modified" descriptor for any given context.

NOTE: The descriptor itself should never be mutated. json-modifiable leaves it up to the user to ensure the patch function is non-mutating. The default patch function is a simple shallow-clone Object.assign:

const patch = Object.assign

Interpolation

json-modifiable uses interpolatable to offer allow interpolation of values into rules/patches. See the docs for how it works. The resolver function passed to json-modifiable will be the same one passed to interpolatable. By default it's just an accessor, but you could also use a resolver that works with json pointer:

Given the rule and the following context:

const rule = {
  when: [
    {
      type: 'object',
      properties: '{{/fields/from/context}}',
      required: '{{/fields/required}}',
    },
  ];
}

const context = {
  fields: {
    from: {
      context: {
        a: {
          type: "strng"
        },
        b: {
          type: "number"
        }
      }
    }
  },
  required: ["a"]
}

You'll end up with the following interpolated rule:

{
  when: [
    {
      type: 'object',
      properties: {
        a: {
          type: "strng"
        },
        b: {
          type: "number"
        }
      }
      required: ["a"]
    },
  ];
}

Interpolations are very powerful and keep your rules serializable.

About interpolation performance

TLDR in performance critical environments where you aren't using interpolation, pass null for the pattern option:

const modifiable = engine(
  myDescriptor, 
  rules, 
  { 
    validator,
    pattern: null
  }
);

Basic Usage

json-modifiable relies on a validator function that evaluates the condition of rules and applies patches

import { engine } from 'json-modifiable';

const descriptor = engine(
  {
    fieldId: 'lastName',
    path: 'user.lastName',
    label: 'Last Name',
    readOnly: false,
    placeholder: 'Enter Your First Name',
    type: 'text',
    hidden: true,
    validations: [],
  },
  validator,
  [
    {
      when: [
        {
          'firstName': {
            type: 'string',
            minLength: 1
          }
        },
      ],
      then: {
        validations: ['required']
      }
    },
    // ... more rules
  ],
);

descriptor.get().validations.find((v) => v === 'required'); // not found
descriptor.setContext({ formData: { firstName: 'fred' } });
descriptor.get().validations.find((v) => v === 'required'); // found!

What in the heck is this good for?

Definining easy to read and easy to apply business logic to things that need to behave differently in different contexts. One use case I've used this for is to quickly and easily perform complicated modifications to form field descriptors based on the state of the form (or some other current application context).

const descriptor = {
  fieldId: 'lastName',
  path: 'user.lastName',
  label: 'Last Name',
  readOnly: false,
  placeholder: 'Enter Your First Name',
  type: 'text',
  hidden: true,
  validations: [],
};

const rules = [
  {
    when: [
      {
        '/formData/firstName': {
          type: 'string',
          minLength: 1,
        },
      },
    ],
    then: {
      validations: ['required'],
      hidden: false
    }
  },
];

JSON Engine

This library also exports a function jsonEngine which is a thin wrapper over the engine using json patch as the patch function and json pointer as the default resolver. You can then write modifiable rules like this:

const myRule: JSONPatchRule<SomeJSONSchema> = {
  when: [
    {
      '/contextPath': {
        type: 'string',
        const: '1',
      },
    },
  ],
  then: [
    {
      op: 'remove',
      path: '/validations/0',
    },
    {
      op: 'replace',
      path: '/someNewKey',
      value: { newThing: 'fred' },
    },
  ],
  otherwise: [
    {
      op: 'remove',
      path: '/validations',
    },
  ],
};

This library internally has tiny, (largely) spec compliant implementations of json patch and json pointer that it uses as the default options for json engine.

The very important difference with the embedded json-patch utility is that it only patches the parts of the descriptor that are actually modified - i.e. no cloneDeep. This allows it to work beautifully with libraries that rely on (or make heavy use of) referential integrity/memoization (like React).

const DynamicFormField = ({ context }) => {

  const refDescriptor = useRef(engine(descriptor, rules, { context }))
  const [currentDescriptor, setCurrentDescriptor] = useState(descriptor.current.get());
  const [context,setContext] = useState({})  

  useEffect(() => {
    return refDescriptor.current.subscribe(setCurrentDescriptor)
  },[])

  useEffect(() => {
    refDescriptor.current.setContext(context);
  },[context])

  return (/* some JSX */)
}

Think outside the box here, what if you didn't have rules for individual field descriptors, but what if you entire form was just modifiable descriptors and the rules governing the entire form were encoded as a bunch of JSON patch operations? Because of the referential integrity of the patches, memo-ed components still work and things are still lightening fast.

const myForm = {
  firstName: {
    label: 'First Name',
    placeholder: 'Enter your first name',
  },
};

const formRules = [
  {
    when: {
      '/formData/firstName': {
        type: 'string',
        pattern: '^A',
      },
    },
    then: [
      {
        op: 'replace',
        path: '/firstName/placeholder',
        value: 'Hey {{/formData/firstName}}, my first name starts with A too!',
      },
    ],
  },
];

Other Cool Stuff

Check out json-schema-rules-engine for a different type of rules engine.

License

MIT

Contributing

PRs welcome!

Package Sidebar

Install

npm i json-modifiable

Weekly Downloads

12

Version

2.0.0

License

MIT

Unpacked Size

49.2 kB

Total Files

16

Last publish

Collaborators

  • akmjenkins