New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

json-modifiable

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

json-modifiable

A rules engine that dynamically modifies your objects using JSON standards

  • 1.2.0
  • Source
  • npm
  • Socket score

Version published
Weekly downloads
82
increased by51.85%
Maintainers
1
Weekly downloads
 
Created
Source

json-modifiable

npm version Coverage Status Build Status Bundle Phobia

An incredibly tiny and configurable rules engine for applying arbitrary modifications to a descriptor based on context. Designed to work best with JSON standards (json pointer, json patch, and json schema) but can work with

  1. JSON Pointer like-syntax - like property-expr or lodash's get
  2. Schema validators like joi or yup.
  3. A custom patch function that accepts a document and the instructions provided in your rules, so you can roll your own patch logic.

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 = createJSONModifiable(...);
</script>

Usage

import createJSONModifiable from 'json-modifiable';

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

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: [['minLength', 2]],
};

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

This library internally has tiny implementations of json patch and json pointer that it uses as default options. It should be noted that the json pointer and json patch implementations can access/modify nested structures that don't currently exist in the descriptor without throwing errors. And the patch operations are a bit looser than the spec - add and replace are treated as synonyms and prescribed errors aren't thrown. Another 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 (or make heavy use of) referential integrity/memoization (like React).

const DynamicFormField = ({ context }) => {

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

  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!',
      },
    ],
  },
];

Validator

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

A validator is the only dependency that must be user supplied. It accepts a schema and an subject to evaluate and it synchronously returns 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 Ajv from 'ajv';

const ajv = new Ajv();
const validator = (schema, subject) => ajv.validate(schema, subject);

const modifiable = createJSONModifiable(myDescriptor, rules, { validator });

You can see that by supplying a different validator, you don't even have to use JSON schema (though we recommend it) in your modifiable rules.

Rules

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

A rule looks like when, then, otherwise where only one of the then or otherwise needs to be defined. A when is made up on an array of objects whose keys are pointers to entities in context and whose values are schemas that will be passed to the validator function.

The when is always an array of Conditions. Conditions are plain objects whose keys are paths and values are schemas.

type Condition = {
  [key: string]: Record<string, any>;
};

// e.g.
const condition = {
  '/formData/firstName': {
    type: 'string',
    minLength: 2,
  },
};

If any of the Conditions in a Rule are true, then the operations in the then clause are applied. If none of them are true then the operations in the otherwise clause are applied. If a rule is false but no otherwise clause is specified, then no patches will be applied. The same goes for if a rule is true but doesn't have a then clause.

Operations

THe then and otherwise must be arrays or Operations. The default implementation requires them to be JSON patch operations. The array of operations in a then or otherwise will be passed to the patch function and the document to apply them to.

type PatchFunction = <T>(descriptor: T, operations: Operation[]) => T;

It's important to know that rules run in the order they have been defined. So your patch operations will be operating on the last modified descriptor.

API

createJSONModifiable<T,C = unknown,Op = JSONPatchOperation>(
  descriptor: T,
  rules: Rule<Op>[],
  options: Options<T,C,Op>
): JSONModifiable<T,C>

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

type Condition = {
  [key: string]: Record<string, unknown>;
};


type Validator = (schema: any, subject: any) => boolean;
type Resolver = (object: Record<string, unknown>, path: string) => any;

type Options<T, C, Op> = {
  // a validator is required
  validator: Validator;
  context?: C;
  pattern?: RegExp | null;
  resolver?: Resolver;
  patch?: (operations: Op[], record: T) => T;
};

type Unsubscribe = () => void;
type Subscriber<T> = (arg: T) => void;

interface JSONModifiable<T, C, Op> {
  get: () => T;
  set: (descriptor: T) => void;
  setRules: (rules: Rule<Op>[]) => void;
  setContext: (context: C) => void;
  subscribe: (subscriber: Subscriber<T>) => Unsubscribe;
  on: (event: 'modified', subscriber: Subscriber<T>) => Unsubscribe;
  on: (event: 'error', subscriber: Subscriber<ErrorEvent>) => Unsubscribe;
}

Interpolation

You can interpolate values from context into your rules and patches using the pattern regexp. By default it uses handlebars-style - e.g. {{thingToInerpolate}}

Note the resolver accepts, by default, a json pointer is used for to evaluate what's being interpolated. So, by default, all interpolation patterns will look like this: {{/path/to/thing/in/context}}

Also useful to know is that you can interpolate more than strings, you can interpolate objects or even arrays.

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 = createJSONModifiable(
  myDescriptor, 
  rules, 
  { 
    validator,
    pattern: null
  }
);

This package uses interpolatable to perform blazing fast interpolations on deeply nested data structures. Interpolatable ensures the expensive operation of traversing nested objects/arrays only happens when absolutely necessary. However, if you don't use interpolation in your rules, your objects will still be traversed the first time your rules are loaded into the engine. If you are operating in a performance critical environment and/or your rules are very large, you can simply pass null for the pattern option to skip this initial traversal as well. In the future, it's possible that the interpolation pattern can be defined per rule for even finer-grained control.

Other Cool Stuff

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

License

MIT

Contributing

PRs welcome!

Keywords

FAQs

Package last updated on 11 Nov 2021

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc