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

0.2.5 • Public • Published

Bonsai

Ever had an integration project with constantly changing data structures? Connecting to public api and want to transform the payload to fit your requirements? Bonsai might be the right choice for you.

With Bonsai you can perform powerful transformations on bulk payloads with little to no code changes using the powerful JSONPath library to query for fields from your source payload and shape them into the data structures you desire. With a easy to use middleware layer you can even do database look ups, further api calls and extremely complicated logic based on the data that is in your source payloads.

Overview

This library provides a means to map a JSON document to another JSON document using JSONPath and a template defined as JSON document.

JSONPath

The JSONPath evaluation is implemented by the Node library jsonpath. Please refer to the documentation of this library for all supported JSONPath expressions.

Here are syntax and examples adapted from Stefan Goessner's original post introducing JSONPath in 2007.

JSONPath Description
$ The root object/element
@ The current object/element
. Child member operator
.. Recursive descendant operator; JSONPath borrows this syntax from E4X
* Wildcard matching all objects/elements regardless their names
[] Subscript operator
[,] Union operator for alternate names or array indices as a set
[start:end:step] Array slice operator borrowed from ES4 / Python
?() Applies a filter (script) expression via static evaluation
() Script expression via static evaluation

Given this sample data set, see example expressions below:

{
  "store": {
    "book": [ 
      {
        "category": "reference",
        "author": "Nigel Rees",
        "title": "Sayings of the Century",
        "price": 8.95
      }, {
        "category": "fiction",
        "author": "Evelyn Waugh",
        "title": "Sword of Honour",
        "price": 12.99
      }, {
        "category": "fiction",
        "author": "Herman Melville",
        "title": "Moby Dick",
        "isbn": "0-553-21311-3",
        "price": 8.99
      }, {
         "category": "fiction",
        "author": "J. R. R. Tolkien",
        "title": "The Lord of the Rings",
        "isbn": "0-395-19395-8",
        "price": 22.99
      }
    ],
    "bicycle": {
      "color": "red",
      "price": 19.95
    }
  }
}

Example JSONPath expressions:

JSONPath Description
$.store.book[*].author The authors of all books in the store
$..author All authors
$.store.* All things in store, which are some books and a red bicycle
$.store..price The price of everything in the store
$..book[2] The third book
$..book[(@.length-1)] The last book via script subscript
$..book[-1:] The last book via slice
$..book[0,1] The first two books via subscript union
$..book[:2] The first two books via subscript array slice
$..book[?(@.isbn)] Filter all books with isbn number
$..book[?(@.price<10)] Filter all books cheaper than 10
$..book[?(@.price==8.95)] Filter all books that cost 8.95
$..book[?(@.price<30 && @.category=="fiction")] Filter all fiction books cheaper than 30
$..* All members of JSON structure

Template

The template document is defined as JSON document itself.

Inspiration

This implementation is modelled on that provided by jsonpath-object-transform. The latest version was published more than 5 years ago.

Usage

import { Mapper } from "bonasi-json";

const template = {
    name: "$.foo.name",
    values: [ "$..foo.items.values" ],
    products: [
        "$.foo.products", 
        {
            sku: "$.id",
            description: "$.info.description",
            price: "$.unitPrice"
        }
    ]
}

const mapper = new Mapper( template );

// load source document
// {
//     "foo": {
//         "name": "Company Holdings Ltd.",
//         "items": {
//             "values": [
//                 "bandana",
//                 "soleless shoes",
//                 "rugsack"
//             ]
//         },
//         "products": [
//             {
//                 "Id": 0001,
//                 "info": {
//                     "description": "bandana",
//                 },
//                 "unitPrice": 12.00
//             },
//             {
//                 "Id": 0002,
//                 "info": {
//                     "description": "soleless shoes",
//                 },
//                 "unitPrice": 120.00
//             },
//             {
//                 "Id": 0003,
//                 "info": {
//                     "description": "rugsack",
//                 },
//                 "unitPrice": 200.00
//             }
//         ]
//     }
// }
const source = ....
const mappedObject = mapper.map( source );

// Output
// {
//     "name": "Company Holdings Ltd.",
//     "values": [
//         "bandana",
//         "soleless shoes",
//         "rugsack"
//     ],
//     "products": [
//         {
//             "sku": 0001,
//             "description": "bandana",
//             "price": 12.00
//         },
//         {
//             "sku": 0001,
//             "description": "soleless shoes",
//             "price": 120.00
//         },
//         {
//             "sku": 0001,
//             "description": "rugsack",
//             "price": 200.00
//         }
//     ]
// }

Template Example: Fetching single property.

{
    propertyName: "$.path.to.field.with.value.in.source"
}

Template Example: Fetching an array.

{
    propertyName: [ "$.path.to.array.field.in.source" ]
}

Template Example: Populating array of values a given field in array of objects.

{
    propertyName: [ "$..path.to.field.in.object.in.source.array" ]
}

Template Example: Transforming array of objects in source.

{
    propertyName: [ 
        "$.path.to.field.with.array.of.objects", 
        {
            propertName: "$.item.field"
        } 
    ]
}

Template Example: Creating a target array manually.

{
    {
        "names": [{ 
            givenName: "$.foo.names[:1].givenName", 
            familyName: "$.foo.names[:1].surname" 
        }]
    }
}

Template Example: Transforming array of objects in source from multiple destinations.

Note that if this must be done in string object pairs as shown. Breaking the pattern below will cause runtime errors

{
    "names": [
        "$.foo.names",
        { givenName: "$.givenName", familyName: "$.surname" },
        "$.some.other.source",
        { givenName: "$.firstName", familyName: "$.lastName" }
    ]
}

Template Example: Merge multiple sources

When you have multiple data payloads and want to merge overwriting any information from right to left you can do this by passing multiple entries into the map function

import { Mapper } from "bonasi-json";

const template = {
    name: "$.foo.name",
    values: [ "$..foo.items.values" ],
    products: [
        "$.foo.products", 
        {
            sku: "$.id",
            description: "$.info.description",
            price: "$.unitPrice"
        }
    ]
}

const mapper = new Mapper( template );

const source = ....
const source1 = ....

const mappedObject = mapper.map( source, source1 );

Any properties in source that differ from source1 will be overridden. Arrays will be concatenated.

Using middleware

Middleware are custom functions passed in by the caller to perform arbitrary functionality on a property before mapping it to the destination

You may define by passing in a Record of them into the mappers constructor.

Mapping with middleware

const mapper = new Mapper(...template, 
        { 
            "$identity": (s, q) => q ? s : undefined,
            "$lookup": lookup,
            "$instanceExample" : this.someFunction
            ...
        }
    );

Writing middleware functions

Middleware functions are operations that operate on some input on the template and provide an output that will be used for the template. The type of a middleware function is:

(data: any, literal?: boolean) => unknown

The data parameter is a value that is defined in the template. The literal parameter is an optional flag which denotes that the data is JSONpath query not a type to act on directly. This is returned by convenience so you can know if the data being passed to your functions is literal or not as defined by the template.

if you want to pass more than one value into your function you will need to make the signature something like this:

$intersection: ({ x, y }: { x: unknown[], y: unknown[] }): unknown[] => ...

Then when your template will look something like this:

{
    "both": {
        "$intersection": {
            "x": "$.daisy",
            "y": "$.paul"
            }
    }
}

Non-query example

Say you have a type with a static property in your target mapping but the field doesn't exist in your source mapping. A way to solve this problem would be to use middleware like so

const mapper = new Mapper(...template, 
        { 
            "$identity": (s, q) => q ? s : undefined
        }
    );

This middleware function simply returns whatever value is passed into it without looking up the value in the source. If the query flag is true then this identity function will return a JSONpath which is useless therefore we return undefined in that case.

The template might look something like this:

{
    "name": "$.bar",
    "type": { "$identity": "TEST_NAME", "literal": true }
}

and the payload might look like this:

{ "bar": "ANAME" }

This indicates that we want to pass the string TEST_NAME literally into the type field. The result will be:

Note that the any string that does not begin with a $ is not attempted to be resolved. The literal exists for valid situations where the strings which are passed through the template begin with a $ and you DO NOT want bonsai to evaluate it.

{ "name": "ANAME", "type": "TEST_NAME" }

Query example

When you want to act on data in the source payload before writing it to the target you will use a query middleware function. These functions will perform a JSONPath query before passing it to your middleware so you can operate on the source data.

Your mapper might look something like:

const mapper = new Mapper(...template, 
        { 
            "$protect": (s) => {
                let i = s.length
                let out = ""
                while (i--) {
                    out += '*'
                }
                return out
            }
        }
    );

This middleware replaces all characters in a "protected" field with asterixs.

The template might look like this:

{
    "name": "$.bar",
    "password": "$.foo",
    "protectedPassword": { "$protect": "$.foo", "literal": false },
}

The template above indicates that:

  • We want to map a value to the protectedPassword property in the target
  • We want to get the foo property in the source
  • We are doing a JSONPath query with the value of the $protect field, not passing it literally.
  • We are applying the $protect middleare to the result of the json query before mapping

The payload might look like:

{ "bar": "ANAME", "foo": "ManagementL1amaWonderC@t" }

and the output would be:

{
    "name": "ANAME",
    "protectedPassword": "************************",
    "password": "ManagementL1amaWonderC@t"
}

Templates with middleware

Passing middleware will enable you to write middleware templates. Templates have this interesting type:

export type MiddleWareTemplate = {
    [key: string]: string
} & {
    literal?: boolean
}

This type means that there is always one property in the type called literal, but there is another field with an arbitrary name that's a string with a property type of string.

An example of this would be:

{
    "$maybe": "$.some.value",
    "literal": false
}

or

{
    "$maybe": "$.some.value",
}

or

{
    "$identity": "HUMAN",
    "literal": true
}

You may also apply middleware on complex objects

{
    "names": { "$orderByName": ["$..name", { "givenName": "$.givenName", "familyName": "$.surname" }], "literal": false }
}

Default middleware

There is an object with default middleware that you can import which contains some utility functions that I found are used often when transforming payloads

To use this in alongside your own custom middleware you can add it to the constructor using the spread operator

import defaultMiddleware from "bonsai/middleware"

const mapper = new Mapper(template, 
        { 
            ...defaultMiddleware,
            "$protect": (s) => {
                let i = s.length
                let out = ""
                while (i--) {
                    out += '*'
                }
                return out
            }
        }
    );

List of default middleware

Below is a list of default middleware and their use cases.

$getFirstElement

Signature: (x: unknown[]): unknown

Description: Use primarily for when operating deep within an hierarchy and you want to simplify a query. Currently the only way to simplify a query is to use the pattern defined in Transforming array of objects in source where you can make subqueries in an array.

{
    propertyName: [ 
        "$.path.to.field.with.array.of.objects", // path from root
        {
            propertName: "$.item.field" // sub path
        } 
    ]
}

you can use the pattern above with the $getFirstElement middleware to create an array of size one and then extract that value as a property instead of an array

Example:

{
    propertyName: {
        $getFirstElement: [ 
            "$.path.to.field.with.array.of.objects", // path from root
            {
                propertName: "$.item.field" // sub path
            } 
        ]
    }
}

$identity

Signature: (x: unknown): unknown

Description: This is primarily used when you want to pass a string value that starts with a dollar sign literally

Example:

{
    value: { $identity "$.Some non query value", literal: true }
}

$mergeObjects

Signature: (x: unknown[]): unknown

Description: Used to merge two objects in source

Example:

template = {
    mergedObjects: { $mergeObjects: "$.foo" }
}
source = {
    foo: [
        {
            value: "cheese"
        },
        {
            number: 1
        },
        {
            deeper: {
                hello: "world"
            }
        }
    ]
}
expectedValue = {
    mergedObjects: {
        value: "cheese",
        number: 1,
        deeper: {
            hello: "world"
        }
    }
}

$override

Signature: ({ x, y }: { x: unknown, y: unknown }): unknown

Description: Will override a value if another value is present

Example:

template = {
    "name": { $override: { x: "$.bar", y: "$.info.name" } },
    "type": {
        complexInnerType: "$.innerType"
    }
}

source = {
    bar: "ANAME",
    innerType: true,
    info: {
        name: "Tony"
    }
}

expectedValue = {
    name: "Tony",
    type: {
        complexInnerType: true
}

$unique

Signature: (list: readonly T[]): T[]

Description: Removes duplicates from a list

Example:

template = {
    onlyOne: { $unique: "$.foo" }
}

source = {
    foo: [1, 1, 2, 4, 4, 4, 4, 4, 5, 7, 8, 8,]
}

expectedValue = { onlyOne: [1, 2, 4, 5, 7, 8] }

$union

Signature: ({ x, y }: { x: unknown[], y: unknown[] }): unknown[]

Description: Get the union of two lists

Example:

template = {
    combined: { $union: { x: "$.daisy", y: "$.paul" } }
}

source = {
    daisy: [
        "apple",
        "pineapple",
        "plumb",
        "orange",
        "pizza",
        "grapes"
    ],
    paul: [
        "kiwi",
        "orange",
        "apricot",
        "apple",
        "banana",
        "pizza"
    ]
}

expectedValue = {
    combined: [
        "apple",
        "pineapple",
        "plumb",
        "orange",
        "pizza",
        "grapes",
        "kiwi",
        "apricot",
        "banana"
    ]
}

$intersection

Signature: ({ x, y }: { x: unknown[], y: unknown[] }): unknown[]

Description: Get only the elements that are common in two lists

Example:

template = {
    both: { $intersection: { x: "$.daisy", y: "$.paul" } }
}

source = {
    daisy: [
        "apple",
        "pineapple",
        "plumb",
        "orange",
        "pizza",
        "grapes"
    ],
    paul: [
        "kiwi",
        "orange",
        "apricot",
        "apple",
        "banana",
        "pizza"
    ]
}

expectedValue = { both: ["apple", "orange", "pizza"] }

$some

Signature: ({ x, arr }: { x: unknown, arr: unknown[] }): boolean

Description: will return true if one of the values are present in the array. Remember that the array can be a queried object so you can create arrays using JSONPath and bonsai that are populated from multiple areas of your source data.

Example:

template = { valid: { $some: { x: "beans", arr: "$.items" } } }

source = {
    items: [
        "cheese",
        "chicken",
        "water",
        "beans"
    ]
}

expectedValue = { valid: true }

$every

Signature: ({ x, arr }: { x: unknown, arr: unknown[] }): boolean

Description: will return true if all of the values in the array are equal to x. Remember that the array can be a queried object so you can create arrays using JSONPath and bonsai that are populated from multiple areas of your source data.

Example:

template = {
    totalSuccess: { $every: { x: "passed", arr: ["$..passed"] } }
}

source = {
    processed: [
        { item: 1, duration: 19.2, passed: "passed" },
        { item: 2, duration: 31, passed: "passed" },
        { item: 3, duration: 104, passed: "passed" },
        { item: 4, duration: 12, passed: "passed" },
        { item: 5, duration: 4, passed: "passed" },
    ]
}

expectedValue = { totalSuccess: true }

Package Sidebar

Install

npm i bonsai-json

Weekly Downloads

0

Version

0.2.5

License

GNU GENERAL PUBLIC LICENSE

Unpacked Size

58.7 kB

Total Files

15

Last publish

Collaborators

  • annogram